mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-26 10:50:43 +00:00
wip - MVP is working. CF worker send and receive working (backend fully working). outstanding issues, new webmail UI full of bugs.
This commit is contained in:
parent
f792e32f8e
commit
2ec0f4ce4f
156 changed files with 17278 additions and 7568 deletions
|
|
@ -32,17 +32,17 @@ services:
|
|||
restart: "no"
|
||||
|
||||
dockflare:
|
||||
#build: ./dockflare
|
||||
image: alplat/dockflare:stable
|
||||
build: ./dockflare
|
||||
#image: alplat/dockflare:stable
|
||||
container_name: dockflare
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
#labels: # -- Cloudflare Tunnel Configuration (via DockFlare) OPTIONAL --
|
||||
- "5001:5000"
|
||||
labels: # -- Cloudflare Tunnel Configuration (via DockFlare) OPTIONAL --
|
||||
# Main DockFlare with access policy
|
||||
#- dockflare.enable=true
|
||||
#- dockflare.hostname=dockflare.example.tld
|
||||
#- dockflare.service=http://dockflare:5000
|
||||
- dockflare.enable=true
|
||||
- dockflare.hostname=unstable.dockflare.app
|
||||
- dockflare.service=http://dockflare:5000
|
||||
#- dockflare.access.group=YOUR-ACCESS-GROUP-ID # your custom access policy
|
||||
# -- OAuth Callback Path (Bypass Access Policy) OPTIONAL --
|
||||
# Required if using OAuth authentication with access policies on main interface
|
||||
|
|
@ -86,9 +86,47 @@ services:
|
|||
networks:
|
||||
- dockflare-internal
|
||||
|
||||
dockflare-mail-manager:
|
||||
build: ./mail-manager
|
||||
#image: alplat/dockflare-mail-manager:stable
|
||||
container_name: dockflare-mail-manager
|
||||
restart: unless-stopped
|
||||
profiles: ["email"]
|
||||
environment:
|
||||
- DOCKFLARE_MASTER_URL=http://dockflare:5000
|
||||
- MAIL_DATA_PATH=/data
|
||||
volumes:
|
||||
- mail_data:/data
|
||||
depends_on:
|
||||
dockflare:
|
||||
condition: service_started
|
||||
networks:
|
||||
- cloudflare-net
|
||||
- dockflare-internal
|
||||
|
||||
dockflare-webmail:
|
||||
build: ./webmail
|
||||
#image: alplat/dockflare-webmail:stable
|
||||
container_name: dockflare-webmail
|
||||
restart: unless-stopped
|
||||
profiles: ["email"]
|
||||
environment:
|
||||
- DOCKFLARE_MASTER_URL=https://unstable.dockflare.app
|
||||
labels:
|
||||
- dockflare.enable=true
|
||||
- dockflare.hostname=mail.dockflare.app # replace with your domain
|
||||
- dockflare.service=http://dockflare-webmail:80
|
||||
depends_on:
|
||||
dockflare-mail-manager:
|
||||
condition: service_started
|
||||
networks:
|
||||
- cloudflare-net
|
||||
- dockflare-internal
|
||||
|
||||
volumes:
|
||||
dockflare_data:
|
||||
dockflare_redis:
|
||||
mail_data:
|
||||
|
||||
networks:
|
||||
cloudflare-net:
|
||||
|
|
|
|||
|
|
@ -251,6 +251,11 @@ def create_app():
|
|||
app_instance.register_blueprint(help_bp)
|
||||
logging.info("Help blueprint registered.")
|
||||
|
||||
from .web.email_routes import email_bp
|
||||
csrf.exempt(email_bp)
|
||||
app_instance.register_blueprint(email_bp)
|
||||
logging.info("Email blueprint registered.")
|
||||
|
||||
return app_instance
|
||||
|
||||
app = create_app()
|
||||
|
|
|
|||
|
|
@ -145,3 +145,12 @@ SYNC_ALL_CLOUDFLARE_POLICIES = os.getenv('SYNC_ALL_CLOUDFLARE_POLICIES', 'false'
|
|||
PRESERVE_UNMANAGED_CF_INGRESS_FIELDS = False
|
||||
|
||||
DOCKFLARE_PUBLIC_URL = os.getenv('DOCKFLARE_PUBLIC_URL', '')
|
||||
|
||||
EMAIL_ENABLED = False
|
||||
EMAIL_CONFIG = {}
|
||||
MAIL_MANAGER_INTERNAL_URL = os.getenv('MAIL_MANAGER_INTERNAL_URL', 'http://dockflare-mail-manager:8025')
|
||||
EMAIL_JWT_ALGORITHM = 'EdDSA'
|
||||
EMAIL_JWT_ISSUER = 'dockflare-master'
|
||||
EMAIL_JWT_AUDIENCE = 'dockflare-mail'
|
||||
EMAIL_JWT_EXPIRY_SECONDS = 3600
|
||||
|
||||
|
|
|
|||
234
dockflare/app/core/email_manager.py
Normal file
234
dockflare/app/core/email_manager.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import logging
|
||||
import requests
|
||||
import json
|
||||
from app import config
|
||||
from app.core.cloudflare_api import cf_api_request, dns_semaphore
|
||||
|
||||
def check_token_permissions():
|
||||
try:
|
||||
perms = {
|
||||
"email_routing": False,
|
||||
"workers": False,
|
||||
"r2": False
|
||||
}
|
||||
token = getattr(config, 'CF_API_TOKEN', '') or ''
|
||||
if token.startswith('cfat_'):
|
||||
verify_res = cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/tokens/verify')
|
||||
else:
|
||||
verify_res = cf_api_request('GET', '/user/tokens/verify')
|
||||
if not verify_res or not verify_res.get('success'):
|
||||
return perms
|
||||
try:
|
||||
cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/email/routing/addresses')
|
||||
perms["email_routing"] = True
|
||||
except Exception:
|
||||
perms["email_routing"] = False
|
||||
try:
|
||||
cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/workers/scripts')
|
||||
perms["workers"] = True
|
||||
except Exception:
|
||||
perms["workers"] = False
|
||||
try:
|
||||
cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/r2/buckets')
|
||||
perms["r2"] = True
|
||||
except Exception as e:
|
||||
perms["r2"] = False
|
||||
if '10042' in str(e):
|
||||
perms["r2_note"] = "R2 must be enabled in the Cloudflare Dashboard before use"
|
||||
return perms
|
||||
except Exception as e:
|
||||
logging.error(f"Error checking token permissions: {e}")
|
||||
return {"email_routing": False, "workers": False, "r2": False}
|
||||
|
||||
def enable_email_routing(zone_id):
|
||||
try:
|
||||
return cf_api_request('POST', f'/zones/{zone_id}/email/routing/enable')
|
||||
except Exception as e:
|
||||
# 422/conflict means already enabled — safe to continue
|
||||
err_str = str(e)
|
||||
if '2004' in err_str or 'already enabled' in err_str.lower() or 'Unprocessable' in err_str:
|
||||
logging.info(f"Email routing already enabled on zone {zone_id}, continuing")
|
||||
return {}
|
||||
logging.error(f"Error enabling email routing: {e}")
|
||||
raise
|
||||
|
||||
def get_email_routing_status(zone_id):
|
||||
try:
|
||||
res = cf_api_request('GET', f'/zones/{zone_id}/email/routing')
|
||||
return res.get('result', {})
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting email routing status: {e}")
|
||||
return {}
|
||||
|
||||
def create_dns_record_generic(zone_id, type, name, content, priority=None):
|
||||
with dns_semaphore:
|
||||
data = {
|
||||
"type": type,
|
||||
"name": name,
|
||||
"content": content,
|
||||
"proxied": False,
|
||||
"ttl": 1
|
||||
}
|
||||
if priority is not None:
|
||||
data["priority"] = priority
|
||||
return cf_api_request('POST', f'/zones/{zone_id}/dns_records', json_data=data)
|
||||
|
||||
def find_dns_record_generic(zone_id, type, name):
|
||||
with dns_semaphore:
|
||||
res = cf_api_request('GET', f'/zones/{zone_id}/dns_records?type={type}&name={name}')
|
||||
if res.get('success') and res.get('result'):
|
||||
return res['result'][0]
|
||||
return None
|
||||
|
||||
def delete_dns_record_generic(zone_id, record_id):
|
||||
with dns_semaphore:
|
||||
return cf_api_request('DELETE', f'/zones/{zone_id}/dns_records/{record_id}')
|
||||
|
||||
def _safe_create_dns(zone_id, type, name, content, priority=None):
|
||||
try:
|
||||
create_dns_record_generic(zone_id, type, name, content, priority)
|
||||
except Exception as e:
|
||||
cf_codes = []
|
||||
err_text = str(e)
|
||||
try:
|
||||
resp = getattr(e, 'response', None)
|
||||
if resp is not None:
|
||||
raw = resp.text
|
||||
err_text = err_text + ' ' + raw
|
||||
cf_codes = [err.get('code') for err in json.loads(raw).get('errors', [])]
|
||||
except Exception:
|
||||
pass
|
||||
cf_code = getattr(e, 'cf_error_code', None)
|
||||
if cf_code:
|
||||
cf_codes.append(cf_code)
|
||||
skip_codes = {81057, 81053, 81058, 890190}
|
||||
if cf_codes and any(c in skip_codes for c in cf_codes):
|
||||
logging.info(f"DNS record {type} {name} skipped (already exists or managed by CF Email Routing, codes={cf_codes})")
|
||||
elif '890190' in err_text or 'already exists' in err_text.lower() or 'managed by Email Routing' in err_text:
|
||||
logging.info(f"DNS record {type} {name} skipped: {err_text[:200]}")
|
||||
else:
|
||||
logging.error(f"DNS record {type} {name} failed, cf_codes={cf_codes}, err={err_text[:500]}")
|
||||
raise
|
||||
|
||||
def setup_email_dns_records(zone_id, zone_name):
|
||||
_safe_create_dns(zone_id, 'MX', zone_name, 'route1.mx.cloudflare.net', priority=14)
|
||||
_safe_create_dns(zone_id, 'MX', zone_name, 'route2.mx.cloudflare.net', priority=36)
|
||||
_safe_create_dns(zone_id, 'MX', zone_name, 'route3.mx.cloudflare.net', priority=88)
|
||||
_safe_create_dns(zone_id, 'TXT', zone_name, 'v=spf1 include:_spf.mx.cloudflare.net ~all')
|
||||
_safe_create_dns(zone_id, 'TXT', f'_dmarc.{zone_name}', f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{zone_name}')
|
||||
|
||||
def verify_email_dns_records(zone_id, zone_name):
|
||||
res = cf_api_request('GET', f'/zones/{zone_id}/dns_records')
|
||||
records = res.get('result', [])
|
||||
status = {'mx': False, 'spf': False, 'dmarc': False}
|
||||
mx_count = 0
|
||||
for r in records:
|
||||
if r['type'] == 'MX' and r['name'] == zone_name and 'mx.cloudflare.net' in r['content']:
|
||||
mx_count += 1
|
||||
if r['type'] == 'TXT' and r['name'] == zone_name and 'v=spf1' in r['content']:
|
||||
status['spf'] = True
|
||||
if r['type'] == 'TXT' and r['name'] == f'_dmarc.{zone_name}' and 'v=DMARC1' in r['content']:
|
||||
status['dmarc'] = True
|
||||
if mx_count >= 3:
|
||||
status['mx'] = True
|
||||
return status
|
||||
|
||||
def create_r2_bucket(bucket_name):
|
||||
try:
|
||||
return cf_api_request('PUT', f'/accounts/{config.CF_ACCOUNT_ID}/r2/buckets/{bucket_name}')
|
||||
except Exception as e:
|
||||
cf_codes = []
|
||||
err_text = str(e)
|
||||
try:
|
||||
resp = getattr(e, 'response', None)
|
||||
if resp is not None:
|
||||
raw = resp.text
|
||||
err_text = err_text + ' ' + raw
|
||||
cf_codes = [err.get('code') for err in json.loads(raw).get('errors', [])]
|
||||
if resp.status_code == 409:
|
||||
logging.info(f"R2 bucket {bucket_name} already exists (409), continuing")
|
||||
return {"success": True, "result": {"name": bucket_name}}
|
||||
except Exception:
|
||||
pass
|
||||
if 10006 in cf_codes or 'already exists' in err_text.lower() or '409' in err_text:
|
||||
logging.info(f"R2 bucket {bucket_name} already exists, continuing")
|
||||
return {"success": True, "result": {"name": bucket_name}}
|
||||
raise
|
||||
|
||||
def get_r2_s3_credentials():
|
||||
token_verify = cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/tokens/verify')
|
||||
token_id = token_verify.get('result', {}).get('id', '')
|
||||
return {
|
||||
'access_key_id': token_id,
|
||||
'secret_access_key': config.CF_API_TOKEN,
|
||||
'endpoint_url': f"https://{config.CF_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
}
|
||||
|
||||
def get_workers_subdomain():
|
||||
res = cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/workers/subdomain')
|
||||
return res.get('result', {}).get('subdomain', '')
|
||||
|
||||
def deploy_worker(script_name, script_content, bindings):
|
||||
url = f"{config.CF_API_BASE_URL}/accounts/{config.CF_ACCOUNT_ID}/workers/scripts/{script_name}"
|
||||
metadata = {
|
||||
"main_module": "worker.js",
|
||||
"bindings": bindings,
|
||||
"compatibility_date": "2024-01-01"
|
||||
}
|
||||
files = {
|
||||
"metadata": (None, json.dumps(metadata), "application/json"),
|
||||
"worker.js": ("worker.js", script_content, "application/javascript+module")
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config.CF_API_TOKEN}"
|
||||
}
|
||||
response = requests.put(url, files=files, headers=headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
try:
|
||||
subdomain_url = f"{config.CF_API_BASE_URL}/accounts/{config.CF_ACCOUNT_ID}/workers/scripts/{script_name}/subdomain"
|
||||
requests.post(subdomain_url, headers=headers, json={"enabled": True})
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not enable workers.dev for {script_name}: {e}")
|
||||
return result
|
||||
|
||||
def delete_worker(script_name):
|
||||
return cf_api_request('DELETE', f'/accounts/{config.CF_ACCOUNT_ID}/workers/scripts/{script_name}')
|
||||
|
||||
def create_email_routing_rule(zone_id, address, worker_name):
|
||||
data = {
|
||||
"matchers": [{"type": "literal", "field": "to", "value": address}],
|
||||
"actions": [{"type": "worker", "value": [worker_name]}],
|
||||
"enabled": True,
|
||||
"name": f"DockFlare: {address}"
|
||||
}
|
||||
return cf_api_request('POST', f'/zones/{zone_id}/email/routing/rules', json_data=data)
|
||||
|
||||
def delete_email_routing_rule(zone_id, rule_id):
|
||||
return cf_api_request('DELETE', f'/zones/{zone_id}/email/routing/rules/{rule_id}')
|
||||
|
||||
def list_email_routing_rules(zone_id):
|
||||
return cf_api_request('GET', f'/zones/{zone_id}/email/routing/rules')
|
||||
|
||||
def setup_catchall_routing_rule(zone_id, worker_name):
|
||||
data = {
|
||||
"matchers": [{"type": "all"}],
|
||||
"actions": [{"type": "worker", "value": [worker_name]}],
|
||||
"enabled": True,
|
||||
"name": "DockFlare: Email Worker Catch-All"
|
||||
}
|
||||
try:
|
||||
current = cf_api_request('GET', f'/zones/{zone_id}/email/routing/rules/catch_all')
|
||||
current_actions = (current.get('result') or {}).get('actions', [])
|
||||
current_worker = None
|
||||
for a in current_actions:
|
||||
if a.get('type') == 'worker':
|
||||
vals = a.get('value', [])
|
||||
current_worker = vals[0] if vals else None
|
||||
if current_worker == worker_name:
|
||||
logging.info(f"Catch-all worker routing rule already correct for zone {zone_id}")
|
||||
return current
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not GET catch_all rule: {e}")
|
||||
logging.info(f"Setting catch-all routing rule to worker {worker_name} via dedicated endpoint")
|
||||
return cf_api_request('PUT', f'/zones/{zone_id}/email/routing/rules/catch_all', json_data=data)
|
||||
62
dockflare/app/core/worker_templates/inbound_worker.js
Normal file
62
dockflare/app/core/worker_templates/inbound_worker.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export default {
|
||||
async email(message, env, ctx) {
|
||||
try {
|
||||
const allowedRecipients = JSON.parse(env.ALLOWED_RECIPIENTS || '[]');
|
||||
if (!allowedRecipients.includes(message.to)) {
|
||||
message.setReject("Recipient not allowed");
|
||||
return;
|
||||
}
|
||||
const messageId = crypto.randomUUID();
|
||||
const r2Key = `temp_cache/${messageId}.eml`;
|
||||
const receivedAt = new Date().toISOString();
|
||||
const rawBytes = await new Response(message.raw).arrayBuffer();
|
||||
await env.EMAIL_BUCKET.put(r2Key, rawBytes, {
|
||||
customMetadata: {
|
||||
from: message.from,
|
||||
to: message.to,
|
||||
subject: message.headers.get("subject") || "",
|
||||
receivedAt: receivedAt
|
||||
}
|
||||
});
|
||||
const sizeBytes = message.rawSize || 0;
|
||||
const payload = {
|
||||
message_id: messageId,
|
||||
from: message.from,
|
||||
to: message.to,
|
||||
subject: message.headers.get("subject") || "",
|
||||
received_at: receivedAt,
|
||||
r2_key: r2Key,
|
||||
size_bytes: sizeBytes
|
||||
};
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(env.WEBHOOK_SECRET),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const signatureBuffer = await crypto.subtle.sign("HMAC", key, encoder.encode(payloadString));
|
||||
const signatureArray = Array.from(new Uint8Array(signatureBuffer));
|
||||
const signatureHex = signatureArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const webhookResponse = await fetch(env.WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-DockFlare-Signature": signatureHex,
|
||||
"X-DockFlare-Message-Id": messageId
|
||||
},
|
||||
body: payloadString
|
||||
});
|
||||
if (!webhookResponse.ok) {
|
||||
const errBody = await webhookResponse.text().catch(() => '');
|
||||
await env.EMAIL_BUCKET.delete(r2Key);
|
||||
message.setReject(`Webhook failed ${webhookResponse.status}: ${errBody.slice(0, 100)}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
message.setReject(`Worker error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
51
dockflare/app/core/worker_templates/outbound_worker.js
Normal file
51
dockflare/app/core/worker_templates/outbound_worker.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { EmailMessage } from "cloudflare:email";
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
if (request.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (authHeader !== `Bearer ${env.AUTH_SECRET}`) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const body = await request.json();
|
||||
const toHeader = Array.isArray(body.to) ? body.to.join(", ") : body.to;
|
||||
const rawTo = Array.isArray(body.to) ? body.to[0] : body.to;
|
||||
const addrMatch = typeof rawTo === 'string' ? rawTo.match(/<([^>]+)>/) : null;
|
||||
const toAddress = addrMatch ? addrMatch[1] : (typeof rawTo === 'string' ? rawTo.trim() : rawTo);
|
||||
let mimeMessage = `From: ${body.from}\r\nTo: ${toHeader}\r\n`;
|
||||
if (body.cc) mimeMessage += `Cc: ${Array.isArray(body.cc) ? body.cc.join(", ") : body.cc}\r\n`;
|
||||
if (body.bcc) mimeMessage += `Bcc: ${Array.isArray(body.bcc) ? body.bcc.join(", ") : body.bcc}\r\n`;
|
||||
mimeMessage += `Subject: ${body.subject}\r\n`;
|
||||
mimeMessage += `Date: ${new Date().toUTCString()}\r\n`;
|
||||
if (body.replyTo) mimeMessage += `Reply-To: ${body.replyTo}\r\n`;
|
||||
if (body.inReplyTo) mimeMessage += `In-Reply-To: ${body.inReplyTo}\r\n`;
|
||||
if (body.references) mimeMessage += `References: ${body.references}\r\n`;
|
||||
if (body.messageId) mimeMessage += `Message-ID: ${body.messageId}\r\n`;
|
||||
mimeMessage += `MIME-Version: 1.0\r\n`;
|
||||
const boundary = "b" + crypto.randomUUID().replace(/-/g, "");
|
||||
mimeMessage += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
|
||||
const textBody = body.text || (body.html ? "" : "(no content)");
|
||||
if (textBody) {
|
||||
mimeMessage += `--${boundary}\r\nContent-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${textBody}\r\n`;
|
||||
}
|
||||
if (body.html) {
|
||||
mimeMessage += `--${boundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${body.html}\r\n`;
|
||||
}
|
||||
mimeMessage += `--${boundary}--\r\n`;
|
||||
const message = new EmailMessage(body.from, toAddress, mimeMessage);
|
||||
try {
|
||||
await env.SEND_EMAIL.send(message);
|
||||
return new Response(JSON.stringify({ success: true, message_id: body.messageId }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({ success: false, error: e.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1903,3 +1903,143 @@ async function deleteIdP(friendlyName) {
|
|||
await dfAlert(t('js.alert.delete_error_generic'), t('js.alert.error_title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function emailCheckPermissions() {
|
||||
try {
|
||||
const response = await fetch('/email/check-permissions', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const p = data.permissions;
|
||||
document.getElementById('permEmailRouting').innerText = p.email_routing ? '✅' : '❌';
|
||||
document.getElementById('permWorkers').innerText = p.workers ? '✅' : '❌';
|
||||
const r2Label = p.r2 ? '✅' : (p.r2_note ? '❌ ' + p.r2_note : '❌');
|
||||
document.getElementById('permR2').innerText = r2Label;
|
||||
const allGranted = p.email_routing && p.workers && p.r2;
|
||||
const banner = document.getElementById('emailPermissionsBanner');
|
||||
if (banner) {
|
||||
banner.classList.toggle('hidden', allGranted);
|
||||
}
|
||||
const setupBtn = document.getElementById('emailSetupBtn');
|
||||
if (setupBtn) {
|
||||
setupBtn.disabled = !allGranted;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailSetupDomain() {
|
||||
const select = document.getElementById('emailZoneSelect');
|
||||
if (!select || !select.value) return;
|
||||
const zoneId = select.value;
|
||||
const zoneName = select.options[select.selectedIndex].text;
|
||||
try {
|
||||
const response = await fetch('/email/setup-domain', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ zone_id: zoneId, zone_name: zoneName })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
await dfAlert(data.error || 'Error', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailTeardownDomain(domain) {
|
||||
if (!await dfConfirm('Are you sure you want to remove this domain?', 'Teardown')) return;
|
||||
try {
|
||||
const response = await fetch('/email/teardown-domain', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ zone_name: domain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) location.reload();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailCreateMailbox() {
|
||||
const address = document.getElementById('newMailboxAddress').value;
|
||||
const domain = document.getElementById('newMailboxDomain').value;
|
||||
const name = document.getElementById('newMailboxName').value;
|
||||
if (!address || !domain) return;
|
||||
try {
|
||||
const response = await fetch('/email/mailbox/create', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ address: address + '@' + domain, domain: domain, display_name: name })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) location.reload();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailDeleteMailbox(address, domain) {
|
||||
if (!await dfConfirm('Delete mailbox?', 'Delete')) return;
|
||||
try {
|
||||
const response = await fetch('/email/mailbox/delete', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ address: address, domain: domain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) location.reload();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailVerifyDns(domain) {
|
||||
try {
|
||||
const response = await fetch('/email/verify-dns', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ zone_name: domain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert(JSON.stringify(data.status), 'DNS Status');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailUpdateR2(domain) {
|
||||
const accessKeyId = prompt('R2 Access Key ID (from CF Dashboard → R2 → Manage R2 API Tokens):');
|
||||
if (!accessKeyId) return;
|
||||
const secretAccessKey = prompt('R2 Secret Access Key:');
|
||||
if (!secretAccessKey) return;
|
||||
try {
|
||||
const response = await fetch('/email/update-r2-credentials', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ zone_name: domain, r2_access_key_id: accessKeyId, r2_secret_access_key: secretAccessKey })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert('R2 credentials updated and mail-manager restarted.', 'Success');
|
||||
} else {
|
||||
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function emailOpenWebmail() {
|
||||
window.location.href = '/email/sso/callback';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@
|
|||
<li><a href="{{ url_for('web.access_policies_page') }}" class="{{ 'active' if request.endpoint == 'web.access_policies_page' else '' }}">{{ t('nav.access_policies') }}</a></li>
|
||||
<li><a href="{{ url_for('web.agents_page') }}" class="{{ 'active' if request.endpoint == 'web.agents_page' else '' }}">{{ t('nav.agents') }}</a></li>
|
||||
<li><a href="{{ url_for('web.settings_page') }}" class="{{ 'active' if request.endpoint == 'web.settings_page' else '' }}">{{ t('nav.settings') }}</a></li>
|
||||
<li><a href="{{ url_for('help.help_page') }}" class="{{ 'active' if request.endpoint.startswith('help.') else '' }}">{{ t('nav.help') }}</a></li>
|
||||
<li><a href="{{ url_for('email.email_page') }}" class="{{ 'active' if request.endpoint == 'email.email_page' else '' }}">{{ t('nav.email') }}</a></li>
|
||||
<li><a href="{{ url_for('help.help_page') }}" class="{{ 'active' if request.endpoint.startswith('help.') else '' }}">{{ t('nav.help') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{{ url_for('web.status_page') }}" class="px-2" title="Now you're thinking with tunnels">
|
||||
|
|
@ -99,6 +100,7 @@
|
|||
<li><a href="{{ url_for('web.access_policies_page') }}" class="{{ 'active' if request.endpoint == 'web.access_policies_page' else '' }}">{{ t('nav.access_policies') }}</a></li>
|
||||
<li><a href="{{ url_for('web.agents_page') }}" class="{{ 'active' if request.endpoint == 'web.agents_page' else '' }}">{{ t('nav.agents') }}</a></li>
|
||||
<li><a href="{{ url_for('web.settings_page') }}" class="{{ 'active' if request.endpoint == 'web.settings_page' else '' }}">{{ t('nav.settings') }}</a></li>
|
||||
<li><a href="{{ url_for('email.email_page') }}" class="{{ 'active' if request.endpoint == 'email.email_page' else '' }}">{{ t('nav.email') }}</a></li>
|
||||
<li><a href="{{ url_for('help.help_page') }}" class="{{ 'active' if request.endpoint.startswith('help.') else '' }}">{{ t('nav.help') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
169
dockflare/app/templates/email.html
Normal file
169
dockflare/app/templates/email.html
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">{{ t('email.title') }}</h1>
|
||||
{% if email_enabled %}
|
||||
<button class="btn btn-primary" onclick="emailOpenWebmail()">{{ t('email.webmail_link') }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="emailPermissionsBanner" class="alert alert-warning shadow-lg mb-8 hidden">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>
|
||||
<h3 class="font-bold">{{ t('email.permissions_title') }}</h3>
|
||||
<div class="text-sm">
|
||||
<span id="permEmailRouting"></span> {{ t('email.permission_email_routing') }}<br>
|
||||
<span id="permWorkers"></span> {{ t('email.permission_workers') }}<br>
|
||||
<span id="permR2"></span> {{ t('email.permission_r2') }}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<button class="btn btn-sm btn-ghost" onclick="emailCheckPermissions()">{{ t('email.recheck_permissions') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.domain_setup') }}</h2>
|
||||
<div class="form-control w-full max-w-xs mb-4">
|
||||
<label class="label"><span class="label-text">{{ t('email.select_zone') }}</span></label>
|
||||
<select id="emailZoneSelect" class="select select-bordered">
|
||||
<option disabled selected>{{ t('email.choose_domain') }}</option>
|
||||
{% for zone in zones %}
|
||||
<option value="{{ zone.id }}">{{ zone.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button id="emailSetupBtn" class="btn btn-primary max-w-xs" onclick="emailSetupDomain()" disabled>{{ t('email.setup_email') }}</button>
|
||||
|
||||
<div class="overflow-x-auto mt-6">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('email.domain') }}</th>
|
||||
<th>{{ t('email.status') }}</th>
|
||||
<th>{{ t('email.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if email_config.domains %}
|
||||
{% for domain, cfg in email_config.domains.items() %}
|
||||
<tr>
|
||||
<td>{{ domain }}</td>
|
||||
<td><div class="badge badge-success">{{ t('email.setup_complete') }}</div></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="emailVerifyDns('{{ domain }}')">{{ t('email.dns_verify') }}</button>
|
||||
<button class="btn btn-sm btn-warning" onclick="emailUpdateR2('{{ domain }}')">R2 Credentials</button>
|
||||
<button class="btn btn-sm btn-error" onclick="emailTeardownDomain('{{ domain }}')">{{ t('email.teardown') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center">{{ t('email.no_domains') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.mailbox_management') }}</h2>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input type="text" id="newMailboxAddress" placeholder="address" class="input input-bordered w-full max-w-xs" />
|
||||
<span class="self-center">@</span>
|
||||
<select id="newMailboxDomain" class="select select-bordered">
|
||||
{% if email_config and email_config.domains %}
|
||||
{% for domain in email_config.domains %}
|
||||
<option value="{{ domain }}">{{ domain }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<input type="text" id="newMailboxName" placeholder="Display Name" class="input input-bordered w-full max-w-xs" />
|
||||
<button class="btn btn-primary" onclick="emailCreateMailbox()">{{ t('email.add_mailbox') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto mt-4">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('email.address') }}</th>
|
||||
<th>{{ t('email.display_name') }}</th>
|
||||
<th>{{ t('email.domain') }}</th>
|
||||
<th>{{ t('email.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if email_config and email_config.domains %}
|
||||
{% for domain, cfg in email_config.domains.items() %}
|
||||
{% for addr, mb in cfg.mailboxes.items() %}
|
||||
<tr>
|
||||
<td>{{ addr }}</td>
|
||||
<td>{{ mb.display_name }}</td>
|
||||
<td>{{ domain }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-error" onclick="emailDeleteMailbox('{{ addr }}', '{{ domain }}')">{{ t('email.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.dns_records') }}</h2>
|
||||
<div id="dnsRecordsContainer">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.statistics') }}</h2>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_received') }}</div>
|
||||
<div class="stat-value" id="statReceived">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_sent') }}</div>
|
||||
<div class="stat-value" id="statSent">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_storage') }}</div>
|
||||
<div class="stat-value" id="statStorage">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_mailboxes') }}</div>
|
||||
<div class="stat-value" id="statMailboxes">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.container_status') }}</h2>
|
||||
<div class="alert alert-info">
|
||||
<div>
|
||||
<span>{{ t('email.container_stopped') }}</span><br>
|
||||
<code class="text-sm mt-2 block">docker compose --profile email up -d</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% block scripts %}
|
||||
<script>document.addEventListener('DOMContentLoaded', emailCheckPermissions);</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
@ -128,6 +128,13 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if email_enabled %}
|
||||
<div class="divider">OR</div>
|
||||
<a href="{{ webmail_url }}" class="btn btn-outline w-full mb-4">
|
||||
{{ t('email.webmail_link') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ def apply_config_to_app(flask_app, config_data: Dict) -> None:
|
|||
config.MASTER_API_KEY = effective_master_key
|
||||
config.DOCKFLARE_PUBLIC_URL = effective_public_url
|
||||
|
||||
email_config = config_data.get('email_config', {})
|
||||
flask_app.config['EMAIL_ENABLED'] = email_config.get('enabled', False)
|
||||
flask_app.config['EMAIL_CONFIG'] = email_config
|
||||
config.EMAIL_ENABLED = flask_app.config['EMAIL_ENABLED']
|
||||
config.EMAIL_CONFIG = flask_app.config['EMAIL_CONFIG']
|
||||
|
||||
if flask_app.config['CF_API_TOKEN']:
|
||||
config.CF_HEADERS['Authorization'] = f"Bearer {flask_app.config['CF_API_TOKEN']}"
|
||||
else:
|
||||
|
|
|
|||
352
dockflare/app/web/email_routes.py
Normal file
352
dockflare/app/web/email_routes.py
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import jwt
|
||||
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from app import config, docker_client
|
||||
from app.core import email_manager
|
||||
from app.core.cloudflare_api import list_account_zones
|
||||
from app.web.config_loader import load_encrypted_config, load_encrypted_config_with_cipher, config_file_path
|
||||
|
||||
_WORKER_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), '..', 'core', 'worker_templates')
|
||||
|
||||
def _read_worker_template(filename):
|
||||
with open(os.path.join(_WORKER_TEMPLATE_DIR, filename), 'r') as f:
|
||||
return f.read()
|
||||
|
||||
email_bp = Blueprint('email', __name__, url_prefix='/email')
|
||||
|
||||
def save_email_config(email_config_data):
|
||||
cfg, fernet = load_encrypted_config_with_cipher()
|
||||
if not cfg or not fernet:
|
||||
return False
|
||||
cfg['email_config'] = email_config_data
|
||||
try:
|
||||
import os
|
||||
from app.web.config_loader import config_file_path
|
||||
cfg_str = json.dumps(cfg)
|
||||
encrypted_data = fernet.encrypt(cfg_str.encode('utf-8'))
|
||||
with open(config_file_path(), 'wb') as f:
|
||||
f.write(encrypted_data)
|
||||
config.EMAIL_CONFIG = email_config_data
|
||||
current_app.config['EMAIL_CONFIG'] = email_config_data
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save email config: {e}")
|
||||
return False
|
||||
|
||||
@email_bp.route('', methods=['GET'])
|
||||
@login_required
|
||||
def email_page():
|
||||
zones = list_account_zones() or []
|
||||
return render_template('email.html', zones=zones, email_config=config.EMAIL_CONFIG, email_enabled=config.EMAIL_ENABLED)
|
||||
|
||||
@email_bp.route('/setup-domain', methods=['POST'])
|
||||
@login_required
|
||||
def setup_email_domain():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
zone_id = data.get('zone_id')
|
||||
zone_name = data.get('zone_name')
|
||||
if not zone_id or not zone_name:
|
||||
return jsonify({'success': False, 'error': 'Missing zone info'}), 400
|
||||
try:
|
||||
try:
|
||||
email_manager.enable_email_routing(zone_id)
|
||||
except Exception as routing_err:
|
||||
logging.warning(f"Could not enable email routing via API (may need manual enable in CF Dashboard): {routing_err}")
|
||||
email_manager.setup_email_dns_records(zone_id, zone_name)
|
||||
bucket_name = f"dockflare-mail-{zone_name.replace('.', '-')}"
|
||||
email_manager.create_r2_bucket(bucket_name)
|
||||
r2_creds = email_manager.get_r2_s3_credentials()
|
||||
r2_access_key_id = r2_creds['access_key_id']
|
||||
r2_secret_access_key = r2_creds['secret_access_key']
|
||||
r2_endpoint_url = r2_creds['endpoint_url']
|
||||
|
||||
workers_subdomain = email_manager.get_workers_subdomain()
|
||||
|
||||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
email_cfg['enabled'] = True
|
||||
if 'domains' not in email_cfg:
|
||||
email_cfg['domains'] = {}
|
||||
|
||||
if 'jwt_signing_key' not in email_cfg:
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_key = private_key.public_key()
|
||||
|
||||
private_bytes = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
public_bytes = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
email_cfg['jwt_signing_key'] = private_bytes.decode('utf-8')
|
||||
email_cfg['jwt_public_key'] = public_bytes.decode('utf-8')
|
||||
|
||||
webhook_secret = secrets.token_hex(32)
|
||||
outbound_auth_secret = secrets.token_hex(32)
|
||||
inbound_worker_name = f"dockflare-mail-inbound-{zone_name.replace('.', '-')}"
|
||||
outbound_worker_name = f"dockflare-mail-outbound-{zone_name.replace('.', '-')}"
|
||||
webmail_hostname = f"mail.{zone_name}"
|
||||
webhook_url = f"https://{webmail_hostname}/api/v1/webhook/inbound"
|
||||
|
||||
inbound_bindings = [
|
||||
{"type": "r2_bucket", "name": "EMAIL_BUCKET", "bucket_name": bucket_name},
|
||||
{"type": "plain_text", "name": "WEBHOOK_URL", "text": webhook_url},
|
||||
{"type": "secret_text", "name": "WEBHOOK_SECRET", "text": webhook_secret},
|
||||
{"type": "plain_text", "name": "ALLOWED_RECIPIENTS", "text": "[]"}
|
||||
]
|
||||
email_manager.deploy_worker(inbound_worker_name, _read_worker_template('inbound_worker.js'), inbound_bindings)
|
||||
email_manager.setup_catchall_routing_rule(zone_id, inbound_worker_name)
|
||||
|
||||
outbound_bindings = [
|
||||
{"type": "send_email", "name": "SEND_EMAIL"},
|
||||
{"type": "secret_text", "name": "AUTH_SECRET", "text": outbound_auth_secret}
|
||||
]
|
||||
email_manager.deploy_worker(outbound_worker_name, _read_worker_template('outbound_worker.js'), outbound_bindings)
|
||||
|
||||
outbound_worker_url = f"https://{outbound_worker_name}.{workers_subdomain}.workers.dev" if workers_subdomain else ''
|
||||
|
||||
email_cfg['domains'][zone_name] = {
|
||||
'zone_id': zone_id,
|
||||
'zone_name': zone_name,
|
||||
'email_routing_enabled': True,
|
||||
'r2_bucket': bucket_name,
|
||||
'r2_access_key_id': r2_access_key_id,
|
||||
'r2_secret_access_key': r2_secret_access_key,
|
||||
'r2_endpoint_url': r2_endpoint_url,
|
||||
'webhook_secret': webhook_secret,
|
||||
'inbound_worker_name': inbound_worker_name,
|
||||
'outbound_worker_name': outbound_worker_name,
|
||||
'outbound_worker_url': outbound_worker_url,
|
||||
'outbound_auth_secret': outbound_auth_secret,
|
||||
'mailboxes': {}
|
||||
}
|
||||
|
||||
save_email_config(email_cfg)
|
||||
config.EMAIL_ENABLED = True
|
||||
current_app.config['EMAIL_ENABLED'] = True
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@email_bp.route('/teardown-domain', methods=['POST'])
|
||||
@login_required
|
||||
def teardown_domain():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
zone_name = data.get('zone_name')
|
||||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
if 'domains' in email_cfg and zone_name in email_cfg['domains']:
|
||||
del email_cfg['domains'][zone_name]
|
||||
save_email_config(email_cfg)
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True})
|
||||
|
||||
def _redeploy_inbound_worker(email_cfg, domain):
|
||||
d = email_cfg['domains'][domain]
|
||||
all_addresses = list(d['mailboxes'].keys())
|
||||
webmail_hostname = f"mail.{domain}"
|
||||
webhook_url = f"https://{webmail_hostname}/api/v1/webhook/inbound"
|
||||
inbound_bindings = [
|
||||
{"type": "r2_bucket", "name": "EMAIL_BUCKET", "bucket_name": d['r2_bucket']},
|
||||
{"type": "plain_text", "name": "WEBHOOK_URL", "text": webhook_url},
|
||||
{"type": "secret_text", "name": "WEBHOOK_SECRET", "text": d['webhook_secret']},
|
||||
{"type": "plain_text", "name": "ALLOWED_RECIPIENTS", "text": json.dumps(all_addresses)}
|
||||
]
|
||||
email_manager.deploy_worker(d['inbound_worker_name'], _read_worker_template('inbound_worker.js'), inbound_bindings)
|
||||
|
||||
@email_bp.route('/mailbox/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_mailbox():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
address = data.get('address')
|
||||
display_name = data.get('display_name')
|
||||
domain = data.get('domain')
|
||||
|
||||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
if 'domains' not in email_cfg or domain not in email_cfg['domains']:
|
||||
return jsonify({'success': False, 'error': 'Domain not configured'}), 400
|
||||
|
||||
zone_id = email_cfg['domains'][domain]['zone_id']
|
||||
worker_name = f"dockflare-mail-inbound-{domain.replace('.', '-')}"
|
||||
|
||||
try:
|
||||
res = email_manager.create_email_routing_rule(zone_id, address, worker_name)
|
||||
rule_id = res.get('result', {}).get('id', '')
|
||||
|
||||
email_cfg['domains'][domain]['mailboxes'][address] = {
|
||||
'display_name': display_name,
|
||||
'routing_rule_id': rule_id,
|
||||
'created_at': time.time()
|
||||
}
|
||||
save_email_config(email_cfg)
|
||||
_redeploy_inbound_worker(email_cfg, domain)
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@email_bp.route('/mailbox/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_mailbox():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
address = data.get('address')
|
||||
domain = data.get('domain')
|
||||
|
||||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
if 'domains' in email_cfg and domain in email_cfg['domains']:
|
||||
if address in email_cfg['domains'][domain]['mailboxes']:
|
||||
rule_id = email_cfg['domains'][domain]['mailboxes'][address].get('routing_rule_id')
|
||||
zone_id = email_cfg['domains'][domain]['zone_id']
|
||||
if rule_id:
|
||||
try:
|
||||
email_manager.delete_email_routing_rule(zone_id, rule_id)
|
||||
except Exception:
|
||||
pass
|
||||
del email_cfg['domains'][domain]['mailboxes'][address]
|
||||
save_email_config(email_cfg)
|
||||
_redeploy_inbound_worker(email_cfg, domain)
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True})
|
||||
|
||||
@email_bp.route('/update-r2-credentials', methods=['POST'])
|
||||
@login_required
|
||||
def update_r2_credentials():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
zone_name = data.get('zone_name')
|
||||
access_key_id = data.get('r2_access_key_id', '').strip()
|
||||
secret_access_key = data.get('r2_secret_access_key', '').strip()
|
||||
if not zone_name or not access_key_id or not secret_access_key:
|
||||
return jsonify({'success': False, 'error': 'Missing required fields'}), 400
|
||||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
if 'domains' not in email_cfg or zone_name not in email_cfg['domains']:
|
||||
return jsonify({'success': False, 'error': 'Domain not configured'}), 404
|
||||
email_cfg['domains'][zone_name]['r2_access_key_id'] = access_key_id
|
||||
email_cfg['domains'][zone_name]['r2_secret_access_key'] = secret_access_key
|
||||
save_email_config(email_cfg)
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True})
|
||||
|
||||
@email_bp.route('/status', methods=['GET'])
|
||||
@login_required
|
||||
def email_status_api():
|
||||
return jsonify({'success': True, 'config': config.EMAIL_CONFIG})
|
||||
|
||||
@email_bp.route('/verify-dns', methods=['POST'])
|
||||
@login_required
|
||||
def verify_dns():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
zone_name = data.get('zone_name')
|
||||
email_cfg = config.EMAIL_CONFIG
|
||||
if 'domains' in email_cfg and zone_name in email_cfg['domains']:
|
||||
zone_id = email_cfg['domains'][zone_name]['zone_id']
|
||||
status = email_manager.verify_email_dns_records(zone_id, zone_name)
|
||||
return jsonify({'success': True, 'status': status})
|
||||
return jsonify({'success': False, 'error': 'Domain not found'}), 404
|
||||
|
||||
@email_bp.route('/check-permissions', methods=['POST'])
|
||||
@login_required
|
||||
def check_permissions():
|
||||
perms = email_manager.check_token_permissions()
|
||||
return jsonify({'success': True, 'permissions': perms})
|
||||
|
||||
@email_bp.route('/generate-jwt', methods=['POST'])
|
||||
@login_required
|
||||
def generate_jwt_route():
|
||||
username = current_user.get_id()
|
||||
token = _generate_jwt(username)
|
||||
if not token:
|
||||
return jsonify({'success': False, 'error': 'JWT config missing'}), 500
|
||||
return jsonify({'success': True, 'token': token})
|
||||
|
||||
@email_bp.route('/sso/callback', methods=['GET'])
|
||||
@login_required
|
||||
def sso_callback():
|
||||
username = current_user.get_id()
|
||||
token = _generate_jwt(username)
|
||||
if not token:
|
||||
return "JWT configuration missing. Please setup email first.", 500
|
||||
return_to = request.args.get('return_to', '')
|
||||
allowed_domains = set()
|
||||
for zone_name in config.EMAIL_CONFIG.get('domains', {}).keys():
|
||||
allowed_domains.add(f"mail.{zone_name}")
|
||||
if not return_to or return_to not in allowed_domains:
|
||||
return_to = next(iter(allowed_domains), '')
|
||||
if not return_to:
|
||||
return "No webmail domain configured.", 500
|
||||
return redirect(f"https://{return_to}/auth/callback?token={token}")
|
||||
|
||||
def _generate_jwt(username):
|
||||
email_cfg = config.EMAIL_CONFIG
|
||||
if not email_cfg or 'jwt_signing_key' not in email_cfg:
|
||||
return None
|
||||
|
||||
private_key_pem = email_cfg['jwt_signing_key']
|
||||
private_key = serialization.load_pem_private_key(
|
||||
private_key_pem.encode('utf-8'),
|
||||
password=None
|
||||
)
|
||||
|
||||
mailboxes = []
|
||||
for d, d_data in email_cfg.get('domains', {}).items():
|
||||
for m in d_data.get('mailboxes', {}).keys():
|
||||
mailboxes.append(m)
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": username,
|
||||
"iss": config.EMAIL_JWT_ISSUER,
|
||||
"aud": config.EMAIL_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + config.EMAIL_JWT_EXPIRY_SECONDS,
|
||||
"mailboxes": mailboxes,
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, private_key, algorithm=config.EMAIL_JWT_ALGORITHM)
|
||||
return token
|
||||
|
||||
@email_bp.route('/internal/config', methods=['GET'])
|
||||
def internal_mail_config():
|
||||
cfg = config.EMAIL_CONFIG
|
||||
if not cfg or not cfg.get('enabled') or not cfg.get('domains'):
|
||||
return jsonify({'configured': False})
|
||||
domains_out = {}
|
||||
for zone_name, d in cfg['domains'].items():
|
||||
domains_out[zone_name] = {
|
||||
'r2_bucket': d.get('r2_bucket', ''),
|
||||
'r2_access_key_id': d.get('r2_access_key_id', ''),
|
||||
'r2_secret_access_key': d.get('r2_secret_access_key', ''),
|
||||
'r2_endpoint_url': d.get('r2_endpoint_url', ''),
|
||||
'webhook_secret': d.get('webhook_secret', ''),
|
||||
'outbound_worker_url': d.get('outbound_worker_url', ''),
|
||||
'outbound_auth_secret': d.get('outbound_auth_secret', ''),
|
||||
'mailboxes': {
|
||||
addr: {'display_name': m.get('display_name', '')}
|
||||
for addr, m in d.get('mailboxes', {}).items()
|
||||
}
|
||||
}
|
||||
return jsonify({
|
||||
'configured': True,
|
||||
'jwt_public_key': cfg.get('jwt_public_key', ''),
|
||||
'jwt_algorithm': config.EMAIL_JWT_ALGORITHM,
|
||||
'jwt_issuer': config.EMAIL_JWT_ISSUER,
|
||||
'jwt_audience': config.EMAIL_JWT_AUDIENCE,
|
||||
'domains': domains_out
|
||||
})
|
||||
|
||||
def _restart_mail_container():
|
||||
try:
|
||||
container = docker_client.containers.get('dockflare-mail-manager')
|
||||
container.restart()
|
||||
logging.info("Restarted dockflare-mail-manager")
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not restart dockflare-mail-manager: {e}")
|
||||
|
|
@ -149,7 +149,7 @@ def gating_logic():
|
|||
|
||||
if not is_configured:
|
||||
|
||||
if request.endpoint and not request.endpoint.startswith('setup.') and request.endpoint != 'static' and not request.endpoint.startswith('api_v2.'):
|
||||
if request.endpoint and not request.endpoint.startswith('setup.') and request.endpoint != 'static' and not request.endpoint.startswith('api_v2.') and request.endpoint != 'email.internal_mail_config':
|
||||
try:
|
||||
if getattr(current_app, 'import_from_env', False):
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ def gating_logic():
|
|||
return
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env']
|
||||
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config']
|
||||
oauth_endpoints = ['web.login_provider', 'web.auth_callback', 'web.login']
|
||||
if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints and request.endpoint not in oauth_endpoints:
|
||||
try:
|
||||
|
|
@ -2316,7 +2316,9 @@ def login():
|
|||
title="Login",
|
||||
form=form,
|
||||
password_login_enabled=password_login_enabled,
|
||||
oauth_providers=oauth_providers
|
||||
oauth_providers=oauth_providers,
|
||||
email_enabled=current_app.config.get('EMAIL_ENABLED', False),
|
||||
webmail_url=url_for('email.sso_callback')
|
||||
)
|
||||
|
||||
@bp.route('/login/<provider_id>')
|
||||
|
|
|
|||
|
|
@ -109,3 +109,4 @@ wrapt==2.1.2
|
|||
# via deprecated
|
||||
wtforms==3.2.1
|
||||
# via flask-wtf
|
||||
PyJWT[crypto]>=2.8.0
|
||||
|
|
|
|||
24
mail-manager/Dockerfile
Normal file
24
mail-manager/Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN groupadd -g 65532 mailmgr && \
|
||||
useradd -u 65532 -g mailmgr -m -s /bin/bash mailmgr
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
COPY entrypoint.py .
|
||||
|
||||
RUN mkdir -p /data/attachments /data/db && \
|
||||
chown -R mailmgr:mailmgr /data
|
||||
|
||||
USER mailmgr
|
||||
|
||||
EXPOSE 8025
|
||||
|
||||
CMD ["python", "entrypoint.py"]
|
||||
10
mail-manager/app/__init__.py
Normal file
10
mail-manager/app/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from flask import Flask
|
||||
from .api.routes import api_bp
|
||||
from .api.webhook import webhook_bp
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object('app.config')
|
||||
app.register_blueprint(api_bp, url_prefix='/api/v1')
|
||||
app.register_blueprint(webhook_bp, url_prefix='/api/v1/webhook')
|
||||
return app
|
||||
0
mail-manager/app/api/__init__.py
Normal file
0
mail-manager/app/api/__init__.py
Normal file
28
mail-manager/app/api/middleware.py
Normal file
28
mail-manager/app/api/middleware.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from functools import wraps
|
||||
from flask import request, jsonify
|
||||
from app.core.jwt_auth import verify_jwt
|
||||
|
||||
def jwt_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid token'}), 401
|
||||
|
||||
token = auth_header.split(' ')[1]
|
||||
decoded = verify_jwt(token)
|
||||
if not decoded:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
|
||||
request.user = decoded
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
@jwt_required
|
||||
def decorated(*args, **kwargs):
|
||||
if request.user.get('role') != 'admin':
|
||||
return jsonify({'error': 'Admin required'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
477
mail-manager/app/api/routes.py
Normal file
477
mail-manager/app/api/routes.py
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
from flask import Blueprint, request, jsonify, send_file
|
||||
from app.core.database import get_db
|
||||
from app.api.middleware import jwt_required, admin_required
|
||||
from app.core.rate_limiter import limiter
|
||||
import json
|
||||
import uuid
|
||||
import os
|
||||
import requests as http_requests
|
||||
from datetime import datetime, timezone
|
||||
from app.config import ATTACHMENTS_PATH, OUTBOUND_WORKER_URL, OUTBOUND_AUTH_SECRET
|
||||
|
||||
api_bp = Blueprint('api', __name__)
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health():
|
||||
db = get_db()
|
||||
db.close()
|
||||
return jsonify({"status": "ok", "version": "1.0.0", "db_size_bytes": 0})
|
||||
|
||||
@api_bp.route('/stats', methods=['GET'])
|
||||
@admin_required
|
||||
def stats():
|
||||
db = get_db()
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM messages")
|
||||
total_messages = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM messages WHERE is_read=0")
|
||||
unread_count = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM send_log")
|
||||
total_sent = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM mailboxes")
|
||||
mailbox_count = cur.fetchone()[0]
|
||||
db.close()
|
||||
return jsonify({
|
||||
"total_messages": total_messages,
|
||||
"unread_count": unread_count,
|
||||
"total_sent": total_sent,
|
||||
"storage_bytes": 0,
|
||||
"mailbox_count": mailbox_count,
|
||||
"messages_today": 0,
|
||||
"sent_today": 0
|
||||
})
|
||||
|
||||
@api_bp.route('/mailboxes', methods=['GET'])
|
||||
@admin_required
|
||||
def get_mailboxes():
|
||||
db = get_db()
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT * FROM mailboxes")
|
||||
res = [dict(row) for row in cur.fetchall()]
|
||||
db.close()
|
||||
return jsonify(res)
|
||||
|
||||
@api_bp.route('/mailboxes', methods=['POST'])
|
||||
@admin_required
|
||||
def create_mailbox():
|
||||
data = request.json
|
||||
db = get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT INTO mailboxes (address, display_name, domain, created_at, is_active) VALUES (?, ?, ?, ?, ?)",
|
||||
(data['address'], data.get('display_name', ''), data['domain'], now, 1)
|
||||
)
|
||||
for folder in ['Inbox', 'Sent', 'Drafts', 'Trash', 'Spam']:
|
||||
db.execute(
|
||||
"INSERT INTO folders (mailbox_address, name, system_folder, created_at) VALUES (?, ?, 1, ?)",
|
||||
(data['address'], folder, now)
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return jsonify({"error": str(e)}), 400
|
||||
finally:
|
||||
db.close()
|
||||
return jsonify({"status": "created"}), 201
|
||||
|
||||
@api_bp.route('/mailboxes/<address>', methods=['GET'])
|
||||
@admin_required
|
||||
def get_mailbox(address):
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT * FROM mailboxes WHERE address=?", (address,))
|
||||
row = cur.fetchone()
|
||||
db.close()
|
||||
if row:
|
||||
return jsonify(dict(row))
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
@api_bp.route('/mailboxes/<address>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_mailbox(address):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM mailboxes WHERE address=?", (address,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>', methods=['PATCH'])
|
||||
@admin_required
|
||||
def patch_mailbox(address):
|
||||
data = request.json
|
||||
if 'display_name' in data:
|
||||
db = get_db()
|
||||
db.execute("UPDATE mailboxes SET display_name=? WHERE address=?", (data['display_name'], address))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "updated"})
|
||||
|
||||
def check_mailbox_access(address):
|
||||
if request.user.get('role') == 'admin':
|
||||
return True
|
||||
return address in request.user.get('mailboxes', [])
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/messages', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_messages(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
folder = request.args.get('folder', 'Inbox')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 50))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name=?", (address, folder))
|
||||
folder_row = cur.fetchone()
|
||||
if not folder_row:
|
||||
db.close()
|
||||
return jsonify({"error": "folder not found"}), 404
|
||||
|
||||
folder_id = folder_row['id']
|
||||
cur = db.execute("SELECT * FROM messages WHERE folder_id=? ORDER BY received_at DESC LIMIT ? OFFSET ?", (folder_id, per_page, offset))
|
||||
msgs = [dict(row) for row in cur.fetchall()]
|
||||
db.close()
|
||||
return jsonify(msgs)
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/messages/<int:msg_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_message(address, msg_id):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT * FROM messages WHERE id=? AND mailbox_address=?", (msg_id, address))
|
||||
msg = cur.fetchone()
|
||||
if not msg:
|
||||
db.close()
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
res = dict(msg)
|
||||
cur = db.execute("SELECT * FROM attachments WHERE message_id=?", (msg_id,))
|
||||
res['attachments'] = [dict(row) for row in cur.fetchall()]
|
||||
db.close()
|
||||
return jsonify(res)
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/messages/<int:msg_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_message(address, msg_id):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT folder_id FROM messages WHERE id=? AND mailbox_address=?", (msg_id, address))
|
||||
msg = cur.fetchone()
|
||||
if not msg:
|
||||
db.close()
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name=?", (address, 'Trash'))
|
||||
trash_id = cur.fetchone()['id']
|
||||
|
||||
if msg['folder_id'] == trash_id:
|
||||
db.execute("DELETE FROM messages WHERE id=?", (msg_id,))
|
||||
else:
|
||||
db.execute("UPDATE messages SET folder_id=? WHERE id=?", (trash_id, msg_id))
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/messages/<int:msg_id>', methods=['PATCH'])
|
||||
@jwt_required
|
||||
def patch_message(address, msg_id):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
data = request.json
|
||||
db = get_db()
|
||||
if 'is_read' in data:
|
||||
db.execute("UPDATE messages SET is_read=? WHERE id=? AND mailbox_address=?", (data['is_read'], msg_id, address))
|
||||
if 'is_starred' in data:
|
||||
db.execute("UPDATE messages SET is_starred=? WHERE id=? AND mailbox_address=?", (data['is_starred'], msg_id, address))
|
||||
if 'folder_id' in data:
|
||||
db.execute("UPDATE messages SET folder_id=? WHERE id=? AND mailbox_address=?", (data['folder_id'], msg_id, address))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "updated"})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/messages/move', methods=['POST'])
|
||||
@jwt_required
|
||||
def bulk_move(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
data = request.json
|
||||
msg_ids = data.get('message_ids', [])
|
||||
folder_id = data.get('folder_id')
|
||||
db = get_db()
|
||||
for mid in msg_ids:
|
||||
db.execute("UPDATE messages SET folder_id=? WHERE id=? AND mailbox_address=?", (folder_id, mid, address))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "moved"})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/messages/mark', methods=['POST'])
|
||||
@jwt_required
|
||||
def bulk_mark(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
data = request.json
|
||||
msg_ids = data.get('message_ids', [])
|
||||
is_read = data.get('is_read')
|
||||
db = get_db()
|
||||
for mid in msg_ids:
|
||||
db.execute("UPDATE messages SET is_read=? WHERE id=? AND mailbox_address=?", (is_read, mid, address))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "marked"})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/folders', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_folders(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT id, name, system_folder FROM folders WHERE mailbox_address=?", (address,))
|
||||
folders = [dict(row) for row in cur.fetchall()]
|
||||
for f in folders:
|
||||
cur = db.execute("SELECT COUNT(*) FROM messages WHERE folder_id=? AND is_read=0", (f['id'],))
|
||||
f['unread_count'] = cur.fetchone()[0]
|
||||
db.close()
|
||||
return jsonify(folders)
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/folders', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_folder(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
name = request.json.get('name')
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db = get_db()
|
||||
try:
|
||||
db.execute("INSERT INTO folders (mailbox_address, name, system_folder, created_at) VALUES (?, ?, 0, ?)", (address, name, now))
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
db.close()
|
||||
return jsonify({"error": str(e)}), 400
|
||||
db.close()
|
||||
return jsonify({"status": "created"}), 201
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/folders/<int:fid>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_folder(address, fid):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT system_folder FROM folders WHERE id=? AND mailbox_address=?", (fid, address))
|
||||
row = cur.fetchone()
|
||||
if row and row['system_folder'] == 1:
|
||||
db.close()
|
||||
return jsonify({"error": "cannot delete system folder"}), 400
|
||||
db.execute("DELETE FROM folders WHERE id=? AND mailbox_address=?", (fid, address))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/search', methods=['GET'])
|
||||
@jwt_required
|
||||
def search_messages(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
q = request.args.get('q', '')
|
||||
folder = request.args.get('folder')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 50))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
db = get_db()
|
||||
query = """
|
||||
SELECT m.* FROM messages m
|
||||
JOIN messages_fts fts ON m.id = fts.rowid
|
||||
WHERE m.mailbox_address = ? AND messages_fts MATCH ?
|
||||
"""
|
||||
params = [address, q]
|
||||
|
||||
if folder:
|
||||
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name=?", (address, folder))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
query += " AND m.folder_id = ?"
|
||||
params.append(row['id'])
|
||||
|
||||
query += " ORDER BY m.received_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([per_page, offset])
|
||||
|
||||
cur = db.execute(query, params)
|
||||
msgs = [dict(row) for row in cur.fetchall()]
|
||||
db.close()
|
||||
return jsonify(msgs)
|
||||
|
||||
def _dispatch_send(address, data):
|
||||
allowed, reason = limiter.check_rate(address)
|
||||
if not allowed:
|
||||
return jsonify({"error": reason}), 429
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
msg_id = f"<{uuid.uuid4()}@{address.split('@')[1]}>"
|
||||
|
||||
worker_payload = {
|
||||
"from": address,
|
||||
"to": data.get('to', []),
|
||||
"cc": data.get('cc'),
|
||||
"bcc": data.get('bcc'),
|
||||
"subject": data.get('subject', ''),
|
||||
"text": data.get('text') or data.get('text_body', ''),
|
||||
"html": data.get('html') or data.get('html_body', ''),
|
||||
"replyTo": data.get('reply_to') or data.get('replyTo'),
|
||||
"inReplyTo": data.get('in_reply_to') or data.get('inReplyTo'),
|
||||
"references": data.get('references'),
|
||||
"messageId": msg_id
|
||||
}
|
||||
|
||||
status = 'sent'
|
||||
error_msg = None
|
||||
worker_resp = None
|
||||
|
||||
if OUTBOUND_WORKER_URL:
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
OUTBOUND_WORKER_URL,
|
||||
json=worker_payload,
|
||||
headers={"Authorization": f"Bearer {OUTBOUND_AUTH_SECRET}"},
|
||||
timeout=30
|
||||
)
|
||||
worker_resp = resp.text
|
||||
if not resp.ok:
|
||||
status = 'failed'
|
||||
error_msg = resp.text
|
||||
except Exception as e:
|
||||
status = 'failed'
|
||||
error_msg = str(e)
|
||||
else:
|
||||
status = 'failed'
|
||||
error_msg = 'Outbound worker not configured'
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO send_log (message_id, from_address, to_addresses, subject, sent_at, status, error_message, worker_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(msg_id, address, json.dumps(data.get('to', [])), data.get('subject', ''), now, status, error_msg, worker_resp)
|
||||
)
|
||||
|
||||
if status == 'sent':
|
||||
limiter.record_send(address)
|
||||
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name='Sent'", (address,))
|
||||
sent_folder = cur.fetchone()
|
||||
if sent_folder:
|
||||
db.execute("""
|
||||
INSERT INTO messages (
|
||||
message_id, mailbox_address, folder_id, from_address, to_addresses, cc_addresses,
|
||||
subject, text_body, html_body, sent_at, is_read, is_starred, is_draft,
|
||||
in_reply_to, reference_ids, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0, ?, ?, ?)
|
||||
""", (
|
||||
msg_id, address, sent_folder['id'], address,
|
||||
json.dumps(data.get('to', [])), json.dumps(data.get('cc', [])),
|
||||
data.get('subject', ''), data.get('text') or data.get('text_body', ''), data.get('html') or data.get('html_body', ''),
|
||||
now, data.get('in_reply_to') or data.get('inReplyTo', ''), data.get('references', ''), now
|
||||
))
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
if status == 'failed':
|
||||
return jsonify({"error": error_msg or "Send failed"}), 502
|
||||
return jsonify({"status": "sent", "message_id": msg_id})
|
||||
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/send', methods=['POST'])
|
||||
@jwt_required
|
||||
def send_email(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
return _dispatch_send(address, request.json)
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/drafts', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_draft(address):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
data = request.json
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name='Drafts'", (address,))
|
||||
folder_id = cur.fetchone()['id']
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
cur = db.execute("""
|
||||
INSERT INTO messages (
|
||||
message_id, mailbox_address, folder_id, to_addresses, subject, text_body, html_body, is_draft, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
""", (str(uuid.uuid4()), address, folder_id, json.dumps(data.get('to', [])), data.get('subject', ''), data.get('text_body', ''), data.get('html_body', ''), now))
|
||||
db.commit()
|
||||
draft_id = cur.lastrowid
|
||||
db.close()
|
||||
return jsonify({"status": "created", "id": draft_id})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/drafts/<int:did>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_draft(address, did):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
data = request.json
|
||||
db = get_db()
|
||||
db.execute("""
|
||||
UPDATE messages SET to_addresses=?, subject=?, text_body=?, html_body=?
|
||||
WHERE id=? AND mailbox_address=? AND is_draft=1
|
||||
""", (json.dumps(data.get('to', [])), data.get('subject', ''), data.get('text_body', ''), data.get('html_body', ''), did, address))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify({"status": "updated"})
|
||||
|
||||
@api_bp.route('/mailboxes/<address>/drafts/<int:did>/send', methods=['POST'])
|
||||
@jwt_required
|
||||
def send_draft(address, did):
|
||||
if not check_mailbox_access(address):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT * FROM messages WHERE id=? AND mailbox_address=? AND is_draft=1", (did, address))
|
||||
draft = cur.fetchone()
|
||||
db.close()
|
||||
if not draft:
|
||||
return jsonify({"error": "draft not found"}), 404
|
||||
draft = dict(draft)
|
||||
send_data = {
|
||||
"to": json.loads(draft.get('to_addresses') or '[]'),
|
||||
"subject": draft.get('subject', ''),
|
||||
"text_body": draft.get('text_body', ''),
|
||||
"html_body": draft.get('html_body', ''),
|
||||
"in_reply_to": draft.get('in_reply_to', ''),
|
||||
"references": draft.get('reference_ids', '')
|
||||
}
|
||||
result = _dispatch_send(address, send_data)
|
||||
if not isinstance(result, tuple):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM messages WHERE id=? AND mailbox_address=? AND is_draft=1", (did, address))
|
||||
db.commit()
|
||||
db.close()
|
||||
return result
|
||||
|
||||
@api_bp.route('/attachments/<int:aid>/download', methods=['GET'])
|
||||
@jwt_required
|
||||
def download_attachment(aid):
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT * FROM attachments WHERE id=?", (aid,))
|
||||
att = cur.fetchone()
|
||||
if not att:
|
||||
db.close()
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
cur = db.execute("SELECT mailbox_address FROM messages WHERE id=?", (att['message_id'],))
|
||||
msg = cur.fetchone()
|
||||
if not msg or not check_mailbox_access(msg['mailbox_address']):
|
||||
db.close()
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
db.close()
|
||||
path = att['storage_path']
|
||||
if not os.path.abspath(path).startswith(os.path.abspath(ATTACHMENTS_PATH)):
|
||||
return jsonify({"error": "invalid path"}), 400
|
||||
|
||||
return send_file(path, as_attachment=True, download_name=att['filename'], mimetype=att['content_type'])
|
||||
92
mail-manager/app/api/webhook.py
Normal file
92
mail-manager/app/api/webhook.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from app.config import WEBHOOK_SECRET, ATTACHMENTS_PATH
|
||||
from app.core.database import get_db
|
||||
from app.core.r2_client import fetch_email_from_r2, delete_from_r2
|
||||
from app.core.mime_parser import parse_eml
|
||||
|
||||
webhook_bp = Blueprint('webhook', __name__)
|
||||
|
||||
def verify_signature(req):
|
||||
signature = req.headers.get('X-DockFlare-Signature')
|
||||
if not signature or not WEBHOOK_SECRET:
|
||||
return False
|
||||
body = req.get_data()
|
||||
expected = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
@webhook_bp.route('/inbound', methods=['POST'])
|
||||
def inbound():
|
||||
if not verify_signature(request):
|
||||
return jsonify({"error": "invalid signature"}), 401
|
||||
|
||||
data = request.json
|
||||
r2_key = data.get('r2_key')
|
||||
if not r2_key:
|
||||
return jsonify({"error": "missing r2_key"}), 400
|
||||
|
||||
try:
|
||||
eml_bytes = fetch_email_from_r2(r2_key)
|
||||
parsed = parse_eml(eml_bytes)
|
||||
|
||||
db = get_db()
|
||||
|
||||
to_address = ''
|
||||
for addr in parsed['to_addresses']:
|
||||
cur = db.execute("SELECT address FROM mailboxes WHERE address=?", (addr,))
|
||||
if cur.fetchone():
|
||||
to_address = addr
|
||||
break
|
||||
|
||||
if not to_address:
|
||||
db.close()
|
||||
return jsonify({"status": "ignored", "reason": "unknown recipient"}), 200
|
||||
|
||||
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name='Inbox'", (to_address,))
|
||||
folder_row = cur.fetchone()
|
||||
folder_id = folder_row['id'] if folder_row else None
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
cur = db.execute("""
|
||||
INSERT INTO messages (
|
||||
message_id, mailbox_address, folder_id, from_address, from_name,
|
||||
to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body,
|
||||
received_at, is_read, is_starred, is_draft, in_reply_to, reference_ids,
|
||||
size_bytes, has_attachments, headers_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
parsed['message_id'], to_address, folder_id, parsed['from_address'], parsed['from_name'],
|
||||
json.dumps(parsed['to_addresses']), json.dumps(parsed['cc_addresses']), json.dumps(parsed['bcc_addresses']),
|
||||
parsed['subject'], parsed['text_body'], parsed['html_body'], parsed['received_at'],
|
||||
parsed['in_reply_to'], parsed['references'], data.get('size_bytes', 0),
|
||||
1 if parsed['attachments'] else 0, json.dumps(parsed['headers_json']), now
|
||||
))
|
||||
msg_id = cur.lastrowid
|
||||
|
||||
for att in parsed['attachments']:
|
||||
att_dir = os.path.join(ATTACHMENTS_PATH, str(msg_id))
|
||||
os.makedirs(att_dir, exist_ok=True)
|
||||
safe_filename = att['filename'].replace('/', '_').replace('\\', '_')
|
||||
att_path = os.path.join(att_dir, safe_filename)
|
||||
with open(att_path, 'wb') as f:
|
||||
f.write(att['data'])
|
||||
|
||||
db.execute("""
|
||||
INSERT INTO attachments (
|
||||
message_id, filename, content_type, size_bytes, storage_path, content_id, is_inline, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (msg_id, att['filename'], att['content_type'], att['size_bytes'], att_path, att['content_id'], att['is_inline'], now))
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
delete_from_r2(r2_key)
|
||||
|
||||
return jsonify({"status": "success"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
16
mail-manager/app/config.py
Normal file
16
mail-manager/app/config.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import os
|
||||
|
||||
JWT_PUBLIC_KEY = os.environ.get('JWT_PUBLIC_KEY', '')
|
||||
JWT_ALGORITHM = os.environ.get('JWT_ALGORITHM', 'EdDSA')
|
||||
JWT_ISSUER = os.environ.get('JWT_ISSUER', 'dockflare-master')
|
||||
JWT_AUDIENCE = os.environ.get('JWT_AUDIENCE', 'dockflare-mail')
|
||||
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', '')
|
||||
R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL', '')
|
||||
R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID', '')
|
||||
R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY', '')
|
||||
R2_BUCKET_NAME = os.environ.get('R2_BUCKET_NAME', '')
|
||||
MAIL_DATA_PATH = os.environ.get('MAIL_DATA_PATH', '/data')
|
||||
OUTBOUND_WORKER_URL = os.environ.get('OUTBOUND_WORKER_URL', '')
|
||||
OUTBOUND_AUTH_SECRET = os.environ.get('OUTBOUND_AUTH_SECRET', '')
|
||||
DB_PATH = os.path.join(MAIL_DATA_PATH, 'db', 'mail.db')
|
||||
ATTACHMENTS_PATH = os.path.join(MAIL_DATA_PATH, 'attachments')
|
||||
0
mail-manager/app/core/__init__.py
Normal file
0
mail-manager/app/core/__init__.py
Normal file
12
mail-manager/app/core/bounce_handler.py
Normal file
12
mail-manager/app/core/bounce_handler.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from app.core.database import get_db
|
||||
|
||||
def log_bounce(original_message_id, bounce_type, recipient, reason):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO bounce_log (original_message_id, bounce_type, recipient, reason, received_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(original_message_id, bounce_type, recipient, reason, datetime.now(timezone.utc).isoformat())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
120
mail-manager/app/core/database.py
Normal file
120
mail-manager/app/core/database.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
from app.config import DB_PATH
|
||||
|
||||
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('PRAGMA journal_mode=WAL')
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS mailboxes (
|
||||
address TEXT PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
domain TEXT,
|
||||
created_at TEXT,
|
||||
is_active INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_address TEXT,
|
||||
name TEXT,
|
||||
system_folder INTEGER,
|
||||
created_at TEXT,
|
||||
UNIQUE(mailbox_address, name),
|
||||
FOREIGN KEY(mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT UNIQUE,
|
||||
mailbox_address TEXT,
|
||||
folder_id INTEGER,
|
||||
from_address TEXT,
|
||||
from_name TEXT,
|
||||
to_addresses TEXT,
|
||||
cc_addresses TEXT,
|
||||
bcc_addresses TEXT,
|
||||
subject TEXT,
|
||||
text_body TEXT,
|
||||
html_body TEXT,
|
||||
received_at TEXT,
|
||||
sent_at TEXT,
|
||||
is_read INTEGER,
|
||||
is_starred INTEGER,
|
||||
is_draft INTEGER,
|
||||
in_reply_to TEXT,
|
||||
reference_ids TEXT,
|
||||
size_bytes INTEGER,
|
||||
has_attachments INTEGER,
|
||||
headers_json TEXT,
|
||||
created_at TEXT,
|
||||
FOREIGN KEY(mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE,
|
||||
FOREIGN KEY(folder_id) REFERENCES folders(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
subject, from_address, from_name, to_addresses, text_body,
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER,
|
||||
filename TEXT,
|
||||
content_type TEXT,
|
||||
size_bytes INTEGER,
|
||||
storage_path TEXT,
|
||||
content_id TEXT,
|
||||
is_inline INTEGER,
|
||||
created_at TEXT,
|
||||
FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS send_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT,
|
||||
from_address TEXT,
|
||||
to_addresses TEXT,
|
||||
subject TEXT,
|
||||
sent_at TEXT,
|
||||
status TEXT,
|
||||
error_message TEXT,
|
||||
worker_response TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bounce_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
original_message_id TEXT,
|
||||
bounce_type TEXT,
|
||||
recipient TEXT,
|
||||
reason TEXT,
|
||||
received_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_mailbox ON messages(mailbox_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_send_log_from ON send_log(from_address);
|
||||
|
||||
DROP TRIGGER IF EXISTS messages_ai;
|
||||
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, subject, from_address, from_name, to_addresses, text_body)
|
||||
VALUES (new.id, new.subject, new.from_address, new.from_name, new.to_addresses, new.text_body);
|
||||
END;
|
||||
DROP TRIGGER IF EXISTS messages_ad;
|
||||
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE rowid = old.id;
|
||||
END;
|
||||
DROP TRIGGER IF EXISTS messages_au;
|
||||
CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE rowid = old.id;
|
||||
INSERT INTO messages_fts(rowid, subject, from_address, from_name, to_addresses, text_body)
|
||||
VALUES (new.id, new.subject, new.from_address, new.from_name, new.to_addresses, new.text_body);
|
||||
END;
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
init_db()
|
||||
15
mail-manager/app/core/jwt_auth.py
Normal file
15
mail-manager/app/core/jwt_auth.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import jwt
|
||||
from app.config import JWT_PUBLIC_KEY, JWT_ALGORITHM, JWT_ISSUER, JWT_AUDIENCE
|
||||
|
||||
def verify_jwt(token):
|
||||
try:
|
||||
decoded = jwt.decode(
|
||||
token,
|
||||
JWT_PUBLIC_KEY,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
issuer=JWT_ISSUER,
|
||||
audience=JWT_AUDIENCE
|
||||
)
|
||||
return decoded
|
||||
except Exception:
|
||||
return None
|
||||
75
mail-manager/app/core/mime_parser.py
Normal file
75
mail-manager/app/core/mime_parser.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import email
|
||||
import email.policy
|
||||
import email.utils
|
||||
from datetime import datetime, timezone
|
||||
import bleach
|
||||
|
||||
def parse_eml(eml_bytes):
|
||||
msg = email.message_from_bytes(eml_bytes, policy=email.policy.default)
|
||||
parsed = {
|
||||
'message_id': msg.get('Message-ID', '').strip('<>'),
|
||||
'from_address': '',
|
||||
'from_name': '',
|
||||
'to_addresses': [],
|
||||
'cc_addresses': [],
|
||||
'bcc_addresses': [],
|
||||
'subject': msg.get('Subject', ''),
|
||||
'date': msg.get('Date'),
|
||||
'in_reply_to': msg.get('In-Reply-To', '').strip('<>'),
|
||||
'references': msg.get('References', ''),
|
||||
'text_body': '',
|
||||
'html_body': '',
|
||||
'attachments': [],
|
||||
'headers_json': []
|
||||
}
|
||||
|
||||
for k, v in msg.items():
|
||||
parsed['headers_json'].append({k: v})
|
||||
|
||||
from_header = msg.get('From', '')
|
||||
if isinstance(from_header, str):
|
||||
parsed['from_address'] = from_header
|
||||
|
||||
for addr_header in ['To', 'Cc', 'Bcc']:
|
||||
val = msg.get(addr_header, '')
|
||||
if val:
|
||||
parsed[f'{addr_header.lower()}_addresses'].append(str(val))
|
||||
|
||||
try:
|
||||
dt = email.utils.parsedate_to_datetime(parsed['date']) if parsed['date'] else datetime.now(timezone.utc)
|
||||
parsed['received_at'] = dt.isoformat()
|
||||
except Exception:
|
||||
parsed['received_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
for part in msg.walk():
|
||||
if part.is_multipart():
|
||||
continue
|
||||
|
||||
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:
|
||||
parsed['text_body'] += part.get_content()
|
||||
except Exception:
|
||||
pass
|
||||
elif content_type == 'text/html' and 'attachment' not in content_disposition:
|
||||
try:
|
||||
raw_html = part.get_content()
|
||||
parsed['html_body'] += bleach.clean(raw_html, tags=bleach.ALLOWED_TAGS + ['p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'u', 'table', 'tbody', 'tr', 'td', 'th', 'thead', 'a', 'img', 'style'], attributes={'*': ['class', 'style', 'id'], 'a': ['href', 'title', 'target'], 'img': ['src', 'alt', 'width', 'height']})
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
filename = part.get_filename() or 'unnamed_attachment'
|
||||
data = part.get_payload(decode=True)
|
||||
if data:
|
||||
parsed['attachments'].append({
|
||||
'filename': filename,
|
||||
'content_type': content_type,
|
||||
'data': data,
|
||||
'content_id': part.get('Content-ID', '').strip('<>'),
|
||||
'is_inline': 1 if 'inline' in content_disposition else 0,
|
||||
'size_bytes': len(data)
|
||||
})
|
||||
|
||||
return parsed
|
||||
22
mail-manager/app/core/r2_client.py
Normal file
22
mail-manager/app/core/r2_client.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import boto3
|
||||
from botocore.config import Config
|
||||
from app.config import R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME
|
||||
|
||||
def get_client():
|
||||
return boto3.client(
|
||||
's3',
|
||||
endpoint_url=R2_ENDPOINT_URL,
|
||||
aws_access_key_id=R2_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=R2_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version='s3v4'),
|
||||
region_name='auto'
|
||||
)
|
||||
|
||||
def fetch_email_from_r2(r2_key):
|
||||
s3 = get_client()
|
||||
resp = s3.get_object(Bucket=R2_BUCKET_NAME, Key=r2_key)
|
||||
return resp['Body'].read()
|
||||
|
||||
def delete_from_r2(r2_key):
|
||||
s3 = get_client()
|
||||
s3.delete_object(Bucket=R2_BUCKET_NAME, Key=r2_key)
|
||||
34
mail-manager/app/core/rate_limiter.py
Normal file
34
mail-manager/app/core/rate_limiter.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import time
|
||||
import threading
|
||||
|
||||
class OutboundRateLimiter:
|
||||
def __init__(self, hourly_limit=50, daily_limit=200):
|
||||
self.hourly_limit = hourly_limit
|
||||
self.daily_limit = daily_limit
|
||||
self.history = {}
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def check_rate(self, from_address):
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
if from_address not in self.history:
|
||||
self.history[from_address] = []
|
||||
|
||||
self.history[from_address] = [t for t in self.history[from_address] if now - t < 86400]
|
||||
|
||||
recent_hour = [t for t in self.history[from_address] if now - t < 3600]
|
||||
|
||||
if len(self.history[from_address]) >= self.daily_limit:
|
||||
return False, "Daily limit reached"
|
||||
if len(recent_hour) >= self.hourly_limit:
|
||||
return False, "Hourly limit reached"
|
||||
|
||||
return True, ""
|
||||
|
||||
def record_send(self, from_address):
|
||||
with self.lock:
|
||||
if from_address not in self.history:
|
||||
self.history[from_address] = []
|
||||
self.history[from_address].append(time.time())
|
||||
|
||||
limiter = OutboundRateLimiter()
|
||||
6
mail-manager/app/main.py
Normal file
6
mail-manager/app/main.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from waitress import serve
|
||||
from app import create_app
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
serve(app, host='0.0.0.0', port=8025)
|
||||
85
mail-manager/entrypoint.py
Normal file
85
mail-manager/entrypoint.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
def bootstrap():
|
||||
master_url = os.environ.get('DOCKFLARE_MASTER_URL', '').rstrip('/')
|
||||
if not master_url:
|
||||
logging.warning("DOCKFLARE_MASTER_URL not set, skipping config bootstrap")
|
||||
return None
|
||||
import requests
|
||||
for attempt in range(15):
|
||||
try:
|
||||
r = requests.get(f"{master_url}/email/internal/config", timeout=5, allow_redirects=False)
|
||||
if r.status_code == 200 and r.content:
|
||||
data = r.json()
|
||||
if data.get('configured'):
|
||||
os.environ['JWT_PUBLIC_KEY'] = data.get('jwt_public_key', '')
|
||||
os.environ['JWT_ALGORITHM'] = data.get('jwt_algorithm', 'EdDSA')
|
||||
os.environ['JWT_ISSUER'] = data.get('jwt_issuer', 'dockflare-master')
|
||||
os.environ['JWT_AUDIENCE'] = data.get('jwt_audience', 'dockflare-mail')
|
||||
domains = data.get('domains', {})
|
||||
if domains:
|
||||
d = next(iter(domains.values()))
|
||||
os.environ['WEBHOOK_SECRET'] = d.get('webhook_secret', '')
|
||||
os.environ['R2_ACCESS_KEY_ID'] = d.get('r2_access_key_id', '')
|
||||
os.environ['R2_SECRET_ACCESS_KEY'] = d.get('r2_secret_access_key', '')
|
||||
os.environ['R2_ENDPOINT_URL'] = d.get('r2_endpoint_url', '')
|
||||
os.environ['R2_BUCKET_NAME'] = d.get('r2_bucket', '')
|
||||
os.environ['OUTBOUND_WORKER_URL'] = d.get('outbound_worker_url', '')
|
||||
os.environ['OUTBOUND_AUTH_SECRET'] = d.get('outbound_auth_secret', '')
|
||||
logging.info("Config bootstrapped from DockFlare Master")
|
||||
else:
|
||||
logging.info("DockFlare Master has no email config yet, starting unconfigured")
|
||||
return data
|
||||
except Exception as e:
|
||||
logging.info(f"Bootstrap attempt {attempt + 1}/15 failed: {e}")
|
||||
time.sleep(2)
|
||||
logging.warning("Could not reach DockFlare Master after 15 attempts, starting with env vars as-is")
|
||||
return None
|
||||
|
||||
|
||||
def _sync_mailboxes(bootstrap_data):
|
||||
if not bootstrap_data or not bootstrap_data.get('configured'):
|
||||
return
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
mail_data_path = os.environ.get('MAIL_DATA_PATH', '/data')
|
||||
db_path = os.path.join(mail_data_path, 'db', 'mail.db')
|
||||
if not os.path.exists(db_path):
|
||||
logging.warning("DB not found during mailbox sync, skipping")
|
||||
return
|
||||
conn = sqlite3.connect(db_path)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
for zone_name, d in bootstrap_data.get('domains', {}).items():
|
||||
for address, mbox in d.get('mailboxes', {}).items():
|
||||
if not conn.execute("SELECT 1 FROM mailboxes WHERE address=?", (address,)).fetchone():
|
||||
conn.execute(
|
||||
"INSERT INTO mailboxes (address, display_name, domain, created_at, is_active) VALUES (?, ?, ?, ?, 1)",
|
||||
(address, mbox.get('display_name', ''), zone_name, now)
|
||||
)
|
||||
for folder in ['Inbox', 'Sent', 'Drafts', 'Trash', 'Spam']:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folders (mailbox_address, name, system_folder, created_at) VALUES (?, ?, 1, ?)",
|
||||
(address, folder, now)
|
||||
)
|
||||
conn.commit()
|
||||
logging.info("Mailbox sync complete")
|
||||
except Exception as e:
|
||||
logging.error(f"Mailbox sync failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
bootstrap_data = bootstrap()
|
||||
|
||||
from waitress import serve
|
||||
from app import create_app
|
||||
|
||||
_sync_mailboxes(bootstrap_data)
|
||||
|
||||
app = create_app()
|
||||
serve(app, host='0.0.0.0', port=8025)
|
||||
9
mail-manager/requirements.txt
Normal file
9
mail-manager/requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
flask
|
||||
flask-limiter
|
||||
waitress
|
||||
boto3
|
||||
PyJWT[crypto]
|
||||
cryptography
|
||||
python-dateutil
|
||||
bleach
|
||||
requests
|
||||
14
webmail/Dockerfile
Normal file
14
webmail/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
EXPOSE 80
|
||||
CMD ["/docker-entrypoint.sh"]
|
||||
15
webmail/components.json
Normal file
15
webmail/components.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/assets/styles/main.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"framework": "vue",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/utils"
|
||||
}
|
||||
}
|
||||
4
webmail/docker-entrypoint.sh
Normal file
4
webmail/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
MASTER_URL="${DOCKFLARE_MASTER_URL:-}"
|
||||
echo "{\"masterUrl\": \"${MASTER_URL}\"}" > /usr/share/nginx/html/config.json
|
||||
exec nginx -g "daemon off;"
|
||||
12
webmail/index.html
Normal file
12
webmail/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DockFlare Webmail</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
webmail/nginx.conf
Normal file
29
webmail/nginx.conf
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 25m;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';";
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://dockflare-mail-manager:8025/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location = /config.json {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|mp4|webm)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
3912
webmail/package-lock.json
generated
Normal file
3912
webmail/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
webmail/package.json
Normal file
38
webmail/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "dockflare-webmail",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^2.6.0",
|
||||
"@tiptap/extension-link": "^2.6.0",
|
||||
"@tiptap/extension-placeholder": "^2.6.0",
|
||||
"@tiptap/extension-typography": "^2.6.0",
|
||||
"@tiptap/starter-kit": "^2.6.0",
|
||||
"@tiptap/vue-3": "^2.6.0",
|
||||
"axios": "^1.7.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.1.0",
|
||||
"lucide-vue-next": "^0.400.0",
|
||||
"pinia": "^2.2.0",
|
||||
"radix-vue": "^1.9.0",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
6
webmail/postcss.config.js
Normal file
6
webmail/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
webmail/src/App.vue
Normal file
7
webmail/src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
28
webmail/src/App.vue.js
Normal file
28
webmail/src/App.vue.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/// <reference types="../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { RouterView } from 'vue-router';
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
const __VLS_0 = {}.RouterView;
|
||||
/** @type {[typeof __VLS_components.RouterView, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({}));
|
||||
const __VLS_2 = __VLS_1({}, ...__VLS_functionalComponentArgsRest(__VLS_1));
|
||||
var __VLS_4 = {};
|
||||
var __VLS_3;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
RouterView: RouterView,
|
||||
};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=App.vue.js.map
|
||||
1
webmail/src/App.vue.js.map
Normal file
1
webmail/src/App.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"App.vue.js","sourceRoot":"","sources":["App.vue"],"names":[],"mappings":"AAMW,8EAA8E;AAEzF,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACvC,QAAQ,CAAA,CAAA,yCAAyC;AAIjD,MAAM,SAAS,GAAG,EAAqE,CAAC;AAExF,IAAI,gBAAiE,CAAC;AAEtE,IAAI,gBAAiE,CAAC;AACtE,MAAM,OAAO,GAAI,EAA+G,CAAC,UAAU,CAAC;AAC5I,qDAAqD,CAAA,CAAC;AACtD,aAAa;AACb,MAAM,OAAO,GAAG,2BAA2B,CAAC,OAAO,EAAE,IAAI,OAAO,CAAC,EAChE,CAAC,CAAC,CAAC;AACJ,MAAM,OAAO,GAAG,OAAO,CAAC,EACvB,EAAE,GAAG,iCAAiC,CAAC,OAAO,CAAC,CAAC,CAAC;AAClD,IAAI,OAAO,GAAG,EAAmE,CAAC;AAClF,IAAI,OAA0E,CAAC;AAO/E,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO;YACP,UAAU,EAAE,UAA+B;SAC1C,CAAC;IACF,CAAC;CACA,CAAC,CAAC;AACH,eAAe,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACrD,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;CACA,CAAC,CAAC;AACH,CAAC,CAAA,kCAAkC"}
|
||||
5
webmail/src/api/auth.js
Normal file
5
webmail/src/api/auth.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import apiClient from './client';
|
||||
export const authApi = {
|
||||
checkAuth: () => apiClient.get('/auth/me'),
|
||||
};
|
||||
//# sourceMappingURL=auth.js.map
|
||||
1
webmail/src/api/auth.js.map
Normal file
1
webmail/src/api/auth.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;CAC3C,CAAA"}
|
||||
5
webmail/src/api/auth.ts
Normal file
5
webmail/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import apiClient from './client'
|
||||
|
||||
export const authApi = {
|
||||
checkAuth: () => apiClient.get('/auth/me'),
|
||||
}
|
||||
25
webmail/src/api/client.js
Normal file
25
webmail/src/api/client.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import axios from 'axios';
|
||||
import router from '../router';
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
apiClient.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('jwt_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
apiClient.interceptors.response.use(response => response, error => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('jwt_token');
|
||||
router.push('/login');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
export default apiClient;
|
||||
//# sourceMappingURL=client.js.map
|
||||
1
webmail/src/api/client.js.map
Normal file
1
webmail/src/api/client.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"client.js","sourceRoot":"","sources":["client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,MAAM,MAAM,WAAW,CAAA;AAE9B,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,CAAC;IAC7B,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,KAAK;IACd,OAAO,EAAE;QACP,cAAc,EAAE,kBAAkB;KACnC;CACF,CAAC,CAAA;AAEF,SAAS,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE;IAC1C,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,CAAC,OAAO,CAAC,aAAa,GAAG,UAAU,KAAK,EAAE,CAAA;IAClD,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC,CAAC,CAAA;AAEF,SAAS,CAAC,YAAY,CAAC,QAAQ,CAAC,GAAG,CACjC,QAAQ,CAAC,EAAE,CAAC,QAAQ,EACpB,KAAK,CAAC,EAAE;IACN,IAAI,KAAK,CAAC,QAAQ,EAAE,MAAM,KAAK,GAAG,EAAE,CAAC;QACnC,YAAY,CAAC,UAAU,CAAC,WAAW,CAAC,CAAA;QACpC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACvB,CAAC;IACD,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC9B,CAAC,CACF,CAAA;AAED,eAAe,SAAS,CAAA"}
|
||||
31
webmail/src/api/client.ts
Normal file
31
webmail/src/api/client.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import axios from 'axios'
|
||||
import router from '../router'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('jwt_token')
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
13
webmail/src/api/mail.js
Normal file
13
webmail/src/api/mail.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import apiClient from './client';
|
||||
export const mailApi = {
|
||||
getMailboxes: () => apiClient.get('/mailboxes'),
|
||||
getFolders: (address) => apiClient.get(`/mailboxes/${address}/folders`),
|
||||
getMessages: (address, params) => apiClient.get(`/mailboxes/${address}/messages`, { params }),
|
||||
getMessage: (address, id) => apiClient.get(`/mailboxes/${address}/messages/${id}`),
|
||||
updateMessage: (address, id, data) => apiClient.patch(`/mailboxes/${address}/messages/${id}`, data),
|
||||
deleteMessage: (address, id) => apiClient.delete(`/mailboxes/${address}/messages/${id}`),
|
||||
sendMessage: (address, data) => apiClient.post(`/mailboxes/${address}/send`, data),
|
||||
searchMessages: (address, params) => apiClient.get(`/mailboxes/${address}/search`, { params }),
|
||||
getAttachmentUrl: (id) => `/api/v1/attachments/${id}/download`
|
||||
};
|
||||
//# sourceMappingURL=mail.js.map
|
||||
1
webmail/src/api/mail.js.map
Normal file
1
webmail/src/api/mail.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"mail.js","sourceRoot":"","sources":["mail.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,YAAY,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,YAAY,CAAC;IAC/C,UAAU,EAAE,CAAC,OAAe,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,cAAc,OAAO,UAAU,CAAC;IAC/E,WAAW,EAAE,CAAC,OAAe,EAAE,MAAW,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,cAAc,OAAO,WAAW,EAAE,EAAE,MAAM,EAAE,CAAC;IAC1G,UAAU,EAAE,CAAC,OAAe,EAAE,EAAU,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,cAAc,OAAO,aAAa,EAAE,EAAE,CAAC;IAClG,aAAa,EAAE,CAAC,OAAe,EAAE,EAAU,EAAE,IAAS,EAAE,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,cAAc,OAAO,aAAa,EAAE,EAAE,EAAE,IAAI,CAAC;IACxH,aAAa,EAAE,CAAC,OAAe,EAAE,EAAU,EAAE,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,cAAc,OAAO,aAAa,EAAE,EAAE,CAAC;IACxG,WAAW,EAAE,CAAC,OAAe,EAAE,IAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,cAAc,OAAO,OAAO,EAAE,IAAI,CAAC;IAC/F,cAAc,EAAE,CAAC,OAAe,EAAE,MAAW,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,cAAc,OAAO,SAAS,EAAE,EAAE,MAAM,EAAE,CAAC;IAC3G,gBAAgB,EAAE,CAAC,EAAU,EAAE,EAAE,CAAC,uBAAuB,EAAE,WAAW;CACvE,CAAA"}
|
||||
13
webmail/src/api/mail.ts
Normal file
13
webmail/src/api/mail.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import apiClient from './client'
|
||||
|
||||
export const mailApi = {
|
||||
getMailboxes: () => apiClient.get('/mailboxes'),
|
||||
getFolders: (address: string) => apiClient.get(`/mailboxes/${address}/folders`),
|
||||
getMessages: (address: string, params: any) => apiClient.get(`/mailboxes/${address}/messages`, { params }),
|
||||
getMessage: (address: string, id: string) => apiClient.get(`/mailboxes/${address}/messages/${id}`),
|
||||
updateMessage: (address: string, id: string, data: any) => apiClient.patch(`/mailboxes/${address}/messages/${id}`, data),
|
||||
deleteMessage: (address: string, id: string) => apiClient.delete(`/mailboxes/${address}/messages/${id}`),
|
||||
sendMessage: (address: string, data: any) => apiClient.post(`/mailboxes/${address}/send`, data),
|
||||
searchMessages: (address: string, params: any) => apiClient.get(`/mailboxes/${address}/search`, { params }),
|
||||
getAttachmentUrl: (id: string) => `/api/v1/attachments/${id}/download`
|
||||
}
|
||||
59
webmail/src/assets/styles/main.css
Normal file
59
webmail/src/assets/styles/main.css
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
20
webmail/src/components/mail/AttachmentBar.vue
Normal file
20
webmail/src/components/mail/AttachmentBar.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { mailApi } from '../../api/mail'
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
defineProps({
|
||||
attachments: { type: Array, default: () => [] }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 border-t p-4" v-if="attachments && attachments.length > 0">
|
||||
<div v-for="att in (attachments as any[])" :key="att.id" class="flex items-center gap-2 rounded-md border p-2 text-sm">
|
||||
<span class="truncate max-w-[200px]">{{ att.filename }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ Math.round(att.size_bytes / 1024) }} KB</span>
|
||||
<Button variant="ghost" size="sm" as="a" :href="mailApi.getAttachmentUrl(att.id)" target="_blank" download>
|
||||
DL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
87
webmail/src/components/mail/AttachmentBar.vue.js
Normal file
87
webmail/src/components/mail/AttachmentBar.vue.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { mailApi } from '../../api/mail';
|
||||
import Button from '../ui/Button.vue';
|
||||
const __VLS_props = defineProps({
|
||||
attachments: { type: Array, default: () => [] }
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
if (__VLS_ctx.attachments && __VLS_ctx.attachments.length > 0) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex flex-wrap gap-2 border-t p-4" },
|
||||
});
|
||||
for (const [att] of __VLS_getVForSourceType(__VLS_ctx.attachments)) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
key: (att.id),
|
||||
...{ class: "flex items-center gap-2 rounded-md border p-2 text-sm" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
|
||||
...{ class: "truncate max-w-[200px]" },
|
||||
});
|
||||
(att.filename);
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
|
||||
...{ class: "text-xs text-muted-foreground" },
|
||||
});
|
||||
(Math.round(att.size_bytes / 1024));
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_0 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
as: "a",
|
||||
href: (__VLS_ctx.mailApi.getAttachmentUrl(att.id)),
|
||||
target: "_blank",
|
||||
download: true,
|
||||
}));
|
||||
const __VLS_1 = __VLS_0({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
as: "a",
|
||||
href: (__VLS_ctx.mailApi.getAttachmentUrl(att.id)),
|
||||
target: "_blank",
|
||||
download: true,
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
|
||||
__VLS_2.slots.default;
|
||||
var __VLS_2;
|
||||
}
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-wrap']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-t']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['truncate']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['max-w-[200px]']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
mailApi: mailApi,
|
||||
Button: Button,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
attachments: { type: Array, default: () => [] }
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
attachments: { type: Array, default: () => [] }
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=AttachmentBar.vue.js.map
|
||||
1
webmail/src/components/mail/AttachmentBar.vue.js.map
Normal file
1
webmail/src/components/mail/AttachmentBar.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"AttachmentBar.vue.js","sourceRoot":"","sources":["AttachmentBar.vue"],"names":[],"mappings":"AAmBW,oFAAoF;AAE/F,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AACxC,OAAO,MAAM,MAAM,kBAAkB,CAAA;AAErC,MAAM,WAAW,GAAG,WAAW,CAAC;IAC9B,WAAW,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;CAChD,CAAC,CAAA;AACF,QAAQ,CAAA,CAAA,yCAAyC;AAIjD,MAAM,SAAS,GAAG,EAAqE,CAAC;AAExF,IAAI,gBAAiE,CAAC;AAEtE,IAAI,gBAAiE,CAAC;AACtE,IAAI,SAAS,CAAC,WAAW,IAAI,SAAS,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;IAChE,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC;QACpF,GAAG,EAAE,KAAK,EAAE,mCAAmC,EAAE;KAChD,CAAC,CAAC;IACH,KAAK,MAAM,CAAC,GAAG,CAAC,IAAI,uBAAuB,CAAG,SAAS,CAAC,WAAuB,CAAC,EAAE,CAAC;QACnF,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC;YACpF,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;YACb,GAAG,EAAE,KAAK,EAAE,uDAAuD,EAAE;SACpE,CAAC,CAAC;QACH,yBAAyB,CAAC,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC;YACtF,GAAG,EAAE,KAAK,EAAE,wBAAwB,EAAE;SACrC,CAAC,CAAC;QACH,CAAE,GAAG,CAAC,QAAQ,CAAE,CAAC;QACjB,yBAAyB,CAAC,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC;YACtF,GAAG,EAAE,KAAK,EAAE,+BAA+B,EAAE;SAC5C,CAAC,CAAC;QACH,CAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,CAAE,CAAC;QACtC,+CAA+C,CAAA,CAAC;QAChD,aAAa;QACb,MAAM,OAAO,GAAG,2BAA2B,CAAC,MAAM,EAAE,IAAI,MAAM,CAAC;YAC/D,OAAO,EAAE,OAAO;YAChB,IAAI,EAAE,IAAI;YACV,EAAE,EAAE,GAAG;YACP,IAAI,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,gBAAgB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAClD,MAAM,EAAE,QAAQ;YAChB,QAAQ,EAAE,IAAI;SACb,CAAC,CAAC,CAAC;QACJ,MAAM,OAAO,GAAG,OAAO,CAAC;YACxB,OAAO,EAAE,OAAO;YAChB,IAAI,EAAE,IAAI;YACV,EAAE,EAAE,GAAG;YACP,IAAI,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,gBAAgB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAClD,MAAM,EAAE,QAAQ;YAChB,QAAQ,EAAE,IAAI;SACb,EAAE,GAAG,iCAAiC,CAAC,OAAO,CAAC,CAAC,CAAC;QAClD,OAAO,CAAC,KAAM,CAAC,OAAO,CAAC;QACvB,IAAI,OAAyE,CAAC;IAC9E,CAAC;AACD,CAAC;AACD,+CAA+C,CAAA,CAAC;AAChD,oDAAoD,CAAA,CAAC;AACrD,gDAAgD,CAAA,CAAC;AACjD,mDAAmD,CAAA,CAAC;AACpD,8CAA8C,CAAA,CAAC;AAC/C,+CAA+C,CAAA,CAAC;AAChD,uDAAuD,CAAA,CAAC;AACxD,gDAAgD,CAAA,CAAC;AACjD,qDAAqD,CAAA,CAAC;AACtD,iDAAiD,CAAA,CAAC;AAClD,8CAA8C,CAAA,CAAC;AAC/C,kDAAkD,CAAA,CAAC;AACnD,mDAAmD,CAAA,CAAC;AACpD,wDAAwD,CAAA,CAAC;AACzD,kDAAkD,CAAA,CAAC;AACnD,gEAAgE,CAAA,CAAC;AAOjE,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO;YACP,OAAO,EAAE,OAAyB;YAClC,MAAM,EAAE,MAAuB;SAC9B,CAAC;IACF,CAAC;IACD,KAAK,EAAE;QACL,WAAW,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;KAChD;CACA,CAAC,CAAC;AACH,eAAe,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACrD,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;IACD,KAAK,EAAE;QACL,WAAW,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;KAChD;CACA,CAAC,CAAC;AACH,CAAC,CAAA,kCAAkC"}
|
||||
82
webmail/src/components/mail/ComposeDialog.vue
Normal file
82
webmail/src/components/mail/ComposeDialog.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { mailApi } from '../../api/mail'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import Dialog from '../ui/Dialog.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Input from '../ui/Input.vue'
|
||||
|
||||
const store = useMailStore()
|
||||
|
||||
const to = ref('')
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
const sending = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
watch(() => store.isComposeOpen, (open) => {
|
||||
if (open && store.composeDefaults) {
|
||||
to.value = store.composeDefaults.to || ''
|
||||
subject.value = store.composeDefaults.subject || ''
|
||||
body.value = store.composeDefaults.body || ''
|
||||
} else if (!open) {
|
||||
reset()
|
||||
}
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
to.value = ''
|
||||
subject.value = ''
|
||||
body.value = ''
|
||||
error.value = ''
|
||||
store.composeDefaults = null
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
store.isComposeOpen = false
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (!store.currentMailbox) return
|
||||
console.log('[ComposeDialog] send() called, body.value=', JSON.stringify(body.value), 'to=', to.value, 'subject=', subject.value)
|
||||
sending.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
to: to.value,
|
||||
subject: subject.value,
|
||||
html: body.value,
|
||||
text: body.value.replace(/<[^>]*>?/gm, '').trim()
|
||||
}
|
||||
console.log('[ComposeDialog] posting payload=', JSON.stringify(payload))
|
||||
await mailApi.sendMessage(store.currentMailbox, payload)
|
||||
close()
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.error || 'Failed to send. Please try again.'
|
||||
console.error(e)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="store.isComposeOpen" @update:open="val => { if (!val) close() }">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lg font-semibold">New Message</div>
|
||||
<Input v-model="to" placeholder="To" />
|
||||
<Input v-model="subject" placeholder="Subject" />
|
||||
<textarea
|
||||
v-model="body"
|
||||
placeholder="Write your message..."
|
||||
class="border rounded-md p-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary min-h-[160px]"
|
||||
@input="(e) => console.log('[ComposeDialog] textarea input, value=', JSON.stringify((e.target as HTMLTextAreaElement).value))"
|
||||
/>
|
||||
<div v-if="error" class="text-sm text-red-500">{{ error }}</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" @click="close">Discard</Button>
|
||||
<Button as="button" type="button" @click.prevent="send" :disabled="sending || !to">{{ sending ? 'Sending…' : 'Send' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
166
webmail/src/components/mail/ComposeDialog.vue.js
Normal file
166
webmail/src/components/mail/ComposeDialog.vue.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { ref } from 'vue';
|
||||
import { mailApi } from '../../api/mail';
|
||||
import { useMailStore } from '../../stores/mail';
|
||||
import Dialog from '../ui/Dialog.vue';
|
||||
import Button from '../ui/Button.vue';
|
||||
import Input from '../ui/Input.vue';
|
||||
import ComposeEditor from './ComposeEditor.vue';
|
||||
const store = useMailStore();
|
||||
const to = ref('');
|
||||
const subject = ref('');
|
||||
const body = ref('');
|
||||
const sending = ref(false);
|
||||
const close = () => store.isComposeOpen = false;
|
||||
const send = async () => {
|
||||
if (!store.currentMailbox)
|
||||
return;
|
||||
sending.value = true;
|
||||
try {
|
||||
await mailApi.sendMessage(store.currentMailbox, {
|
||||
to: to.value,
|
||||
subject: subject.value,
|
||||
html: body.value,
|
||||
text: body.value.replace(/<[^>]*>?/gm, '')
|
||||
});
|
||||
close();
|
||||
to.value = '';
|
||||
subject.value = '';
|
||||
body.value = '';
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
/** @type {[typeof Dialog, typeof Dialog, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_0 = __VLS_asFunctionalComponent(Dialog, new Dialog({
|
||||
...{ 'onUpdate:open': {} },
|
||||
open: (__VLS_ctx.store.isComposeOpen),
|
||||
}));
|
||||
const __VLS_1 = __VLS_0({
|
||||
...{ 'onUpdate:open': {} },
|
||||
open: (__VLS_ctx.store.isComposeOpen),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
|
||||
let __VLS_3;
|
||||
let __VLS_4;
|
||||
let __VLS_5;
|
||||
const __VLS_6 = {
|
||||
'onUpdate:open': (val => __VLS_ctx.store.isComposeOpen = val)
|
||||
};
|
||||
var __VLS_7 = {};
|
||||
__VLS_2.slots.default;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex flex-col gap-4" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "text-lg font-semibold" },
|
||||
});
|
||||
/** @type {[typeof Input, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_8 = __VLS_asFunctionalComponent(Input, new Input({
|
||||
modelValue: (__VLS_ctx.to),
|
||||
placeholder: "To",
|
||||
}));
|
||||
const __VLS_9 = __VLS_8({
|
||||
modelValue: (__VLS_ctx.to),
|
||||
placeholder: "To",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_8));
|
||||
/** @type {[typeof Input, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_11 = __VLS_asFunctionalComponent(Input, new Input({
|
||||
modelValue: (__VLS_ctx.subject),
|
||||
placeholder: "Subject",
|
||||
}));
|
||||
const __VLS_12 = __VLS_11({
|
||||
modelValue: (__VLS_ctx.subject),
|
||||
placeholder: "Subject",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_11));
|
||||
/** @type {[typeof ComposeEditor, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_14 = __VLS_asFunctionalComponent(ComposeEditor, new ComposeEditor({
|
||||
modelValue: (__VLS_ctx.body),
|
||||
}));
|
||||
const __VLS_15 = __VLS_14({
|
||||
modelValue: (__VLS_ctx.body),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_14));
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex justify-end gap-2" },
|
||||
});
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_17 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
...{ 'onClick': {} },
|
||||
variant: "ghost",
|
||||
}));
|
||||
const __VLS_18 = __VLS_17({
|
||||
...{ 'onClick': {} },
|
||||
variant: "ghost",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_17));
|
||||
let __VLS_20;
|
||||
let __VLS_21;
|
||||
let __VLS_22;
|
||||
const __VLS_23 = {
|
||||
onClick: (__VLS_ctx.close)
|
||||
};
|
||||
__VLS_19.slots.default;
|
||||
var __VLS_19;
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_24 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
...{ 'onClick': {} },
|
||||
disabled: (__VLS_ctx.sending || !__VLS_ctx.to),
|
||||
}));
|
||||
const __VLS_25 = __VLS_24({
|
||||
...{ 'onClick': {} },
|
||||
disabled: (__VLS_ctx.sending || !__VLS_ctx.to),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_24));
|
||||
let __VLS_27;
|
||||
let __VLS_28;
|
||||
let __VLS_29;
|
||||
const __VLS_30 = {
|
||||
onClick: (__VLS_ctx.send)
|
||||
};
|
||||
__VLS_26.slots.default;
|
||||
var __VLS_26;
|
||||
var __VLS_2;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-lg']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['justify-end']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
Dialog: Dialog,
|
||||
Button: Button,
|
||||
Input: Input,
|
||||
ComposeEditor: ComposeEditor,
|
||||
store: store,
|
||||
to: to,
|
||||
subject: subject,
|
||||
body: body,
|
||||
sending: sending,
|
||||
close: close,
|
||||
send: send,
|
||||
};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=ComposeDialog.vue.js.map
|
||||
1
webmail/src/components/mail/ComposeDialog.vue.js.map
Normal file
1
webmail/src/components/mail/ComposeDialog.vue.js.map
Normal file
File diff suppressed because one or more lines are too long
39
webmail/src/components/mail/ComposeEditor.vue
Normal file
39
webmail/src/components/mail/ComposeEditor.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const store = useMailStore()
|
||||
|
||||
const text = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
text.value = val
|
||||
store.composeBody = val
|
||||
})
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const val = (e.target as HTMLTextAreaElement).value
|
||||
text.value = val
|
||||
store.composeBody = val
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
const getHTML = () => text.value
|
||||
defineExpose({ getHTML })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col border rounded-md overflow-hidden">
|
||||
<textarea
|
||||
:value="text"
|
||||
@input="onInput"
|
||||
placeholder="Write your message..."
|
||||
class="flex-1 p-4 text-sm resize-none focus:outline-none min-h-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
114
webmail/src/components/mail/ComposeEditor.vue.js
Normal file
114
webmail/src/components/mail/ComposeEditor.vue.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Typography from '@tiptap/extension-typography';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' }
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link,
|
||||
Image,
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: 'Write your message...' })
|
||||
],
|
||||
onUpdate: ({ editor }) => {
|
||||
emit('update:modelValue', editor.getHTML());
|
||||
}
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex flex-col border rounded-md h-64 overflow-hidden" },
|
||||
});
|
||||
if (__VLS_ctx.editor) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex flex-wrap gap-1 p-1 border-b bg-muted/50" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
if (!(__VLS_ctx.editor))
|
||||
return;
|
||||
__VLS_ctx.editor.chain().focus().toggleBold().run();
|
||||
} },
|
||||
...{ class: (['px-2 py-1 rounded text-sm', __VLS_ctx.editor.isActive('bold') ? 'bg-muted font-bold' : '']) },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
if (!(__VLS_ctx.editor))
|
||||
return;
|
||||
__VLS_ctx.editor.chain().focus().toggleItalic().run();
|
||||
} },
|
||||
...{ class: (['px-2 py-1 rounded text-sm', __VLS_ctx.editor.isActive('italic') ? 'bg-muted italic' : '']) },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
if (!(__VLS_ctx.editor))
|
||||
return;
|
||||
__VLS_ctx.editor.chain().focus().toggleStrike().run();
|
||||
} },
|
||||
...{ class: (['px-2 py-1 rounded text-sm', __VLS_ctx.editor.isActive('strike') ? 'bg-muted line-through' : '']) },
|
||||
});
|
||||
}
|
||||
const __VLS_0 = {}.EditorContent;
|
||||
/** @type {[typeof __VLS_components.EditorContent, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({
|
||||
editor: (__VLS_ctx.editor),
|
||||
...{ class: "flex-1 overflow-y-auto p-4 prose max-w-none dark:prose-invert focus:outline-none" },
|
||||
}));
|
||||
const __VLS_2 = __VLS_1({
|
||||
editor: (__VLS_ctx.editor),
|
||||
...{ class: "flex-1 overflow-y-auto p-4 prose max-w-none dark:prose-invert focus:outline-none" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['h-64']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['overflow-hidden']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-wrap']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-b']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['bg-muted/50']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['overflow-y-auto']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['prose']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['max-w-none']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['dark:prose-invert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['focus:outline-none']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
EditorContent: EditorContent,
|
||||
editor: editor,
|
||||
};
|
||||
},
|
||||
emits: {},
|
||||
props: {
|
||||
modelValue: { type: String, default: '' }
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
emits: {},
|
||||
props: {
|
||||
modelValue: { type: String, default: '' }
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=ComposeEditor.vue.js.map
|
||||
1
webmail/src/components/mail/ComposeEditor.vue.js.map
Normal file
1
webmail/src/components/mail/ComposeEditor.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"ComposeEditor.vue.js","sourceRoot":"","sources":["ComposeEditor.vue"],"names":[],"mappings":"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"}
|
||||
22
webmail/src/components/mail/FolderNav.vue
Normal file
22
webmail/src/components/mail/FolderNav.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const selectFolder = (name: string) => {
|
||||
store.currentFolder = name
|
||||
emit('select', name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-1 p-2">
|
||||
<button v-for="f in store.folders" :key="f.name"
|
||||
@click="selectFolder(f.name)"
|
||||
:class="['flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground', store.currentFolder === f.name ? 'bg-accent text-accent-foreground' : 'transparent']">
|
||||
{{ f.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
47
webmail/src/components/mail/FolderNav.vue.js
Normal file
47
webmail/src/components/mail/FolderNav.vue.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { useMailStore } from '../../stores/mail';
|
||||
const store = useMailStore();
|
||||
const emit = defineEmits(['select']);
|
||||
const selectFolder = (name) => {
|
||||
store.currentFolder = name;
|
||||
emit('select', name);
|
||||
};
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.nav, __VLS_intrinsicElements.nav)({
|
||||
...{ class: "flex flex-col gap-1 p-2" },
|
||||
});
|
||||
for (const [f] of __VLS_getVForSourceType((__VLS_ctx.store.folders))) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
__VLS_ctx.selectFolder(f.name);
|
||||
} },
|
||||
key: (f.name),
|
||||
...{ class: (['flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground', __VLS_ctx.store.currentFolder === f.name ? 'bg-accent text-accent-foreground' : 'transparent']) },
|
||||
});
|
||||
(f.name);
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-2']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
store: store,
|
||||
selectFolder: selectFolder,
|
||||
};
|
||||
},
|
||||
emits: {},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
emits: {},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=FolderNav.vue.js.map
|
||||
1
webmail/src/components/mail/FolderNav.vue.js.map
Normal file
1
webmail/src/components/mail/FolderNav.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"FolderNav.vue.js","sourceRoot":"","sources":["FolderNav.vue"],"names":[],"mappings":"AAqBW,oFAAoF;AAE/F,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,MAAM,KAAK,GAAG,YAAY,EAAE,CAAA;AAE5B,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAA;AAEpC,MAAM,YAAY,GAAG,CAAC,IAAY,EAAE,EAAE;IACpC,KAAK,CAAC,aAAa,GAAG,IAAI,CAAA;IAC1B,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;AACtB,CAAC,CAAA;AACD,QAAQ,CAAA,CAAA,yCAAyC;AAIjD,MAAM,SAAS,GAAG,EAAqE,CAAC;AAExF,IAAI,gBAAiE,CAAC;AAEtE,IAAI,gBAAiE,CAAC;AACtE,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC;IACpF,GAAG,EAAE,KAAK,EAAE,yBAAyB,EAAE;CACtC,CAAC,CAAC;AACH,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,uBAAuB,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,OAAO,CAAE,CAAC,EAAE,CAAC;IACxE,yBAAyB,CAAC,uBAAuB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC;QAC1F,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE;gBAC9B,SAAS,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;YAC/B,CAAC,EAAC;QACF,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QACb,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,+GAA+G,EAAE,SAAS,CAAC,KAAK,CAAC,aAAa,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,kCAAkC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,EAAE;KAC/N,CAAC,CAAC;IACH,CAAE,CAAC,CAAC,IAAI,CAAE,CAAC;AACX,CAAC;AACD,+CAA+C,CAAA,CAAC;AAChD,mDAAmD,CAAA,CAAC;AACpD,gDAAgD,CAAA,CAAC;AACjD,8CAA8C,CAAA,CAAC;AAO/C,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO;YACP,KAAK,EAAE,KAAqB;YAC5B,YAAY,EAAE,YAAmC;SAChD,CAAC;IACF,CAAC;IACD,KAAK,EAAE,EAAuC;CAC7C,CAAC,CAAC;AACH,eAAe,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACrD,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;IACD,KAAK,EAAE,EAAuC;CAC7C,CAAC,CAAC;AACH,CAAC,CAAA,kCAAkC"}
|
||||
49
webmail/src/components/mail/MailLayout.vue
Normal file
49
webmail/src/components/mail/MailLayout.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import ResizablePanelGroup from '../ui/ResizablePanelGroup.vue'
|
||||
import ResizablePanel from '../ui/ResizablePanel.vue'
|
||||
import ResizableHandle from '../ui/ResizableHandle.vue'
|
||||
import FolderNav from './FolderNav.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
import MessageDisplay from './MessageDisplay.vue'
|
||||
import MailboxSelector from './MailboxSelector.vue'
|
||||
import SearchBar from './SearchBar.vue'
|
||||
import ComposeDialog from './ComposeDialog.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
|
||||
const store = useMailStore()
|
||||
const { logout } = useAuth()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen w-screen overflow-hidden bg-background flex flex-col">
|
||||
<header class="flex h-14 items-center justify-between border-b px-4">
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
DockFlare Webmail
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="store.isComposeOpen = true">Compose</Button>
|
||||
<Button variant="ghost" size="sm" @click="logout">Logout</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ResizablePanelGroup class="flex-1">
|
||||
<ResizablePanel :defaultSize="20" :minSize="15" class="border-r flex flex-col hidden md:flex">
|
||||
<MailboxSelector />
|
||||
<FolderNav @select="() => {}" class="flex-1 overflow-auto" />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel :defaultSize="35" :minSize="25" class="border-r flex flex-col hidden sm:flex">
|
||||
<SearchBar />
|
||||
<MessageList class="flex-1 overflow-auto" />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel :defaultSize="45" :minSize="30" class="flex-1">
|
||||
<MessageDisplay :message="store.currentMessage" />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<ComposeDialog />
|
||||
</div>
|
||||
</template>
|
||||
239
webmail/src/components/mail/MailLayout.vue.js
Normal file
239
webmail/src/components/mail/MailLayout.vue.js
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import ResizablePanelGroup from '../ui/ResizablePanelGroup.vue';
|
||||
import ResizablePanel from '../ui/ResizablePanel.vue';
|
||||
import ResizableHandle from '../ui/ResizableHandle.vue';
|
||||
import FolderNav from './FolderNav.vue';
|
||||
import MessageList from './MessageList.vue';
|
||||
import MessageDisplay from './MessageDisplay.vue';
|
||||
import MailboxSelector from './MailboxSelector.vue';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
import ComposeDialog from './ComposeDialog.vue';
|
||||
import Button from '../ui/Button.vue';
|
||||
import { useMailStore } from '../../stores/mail';
|
||||
import { useAuth } from '../../composables/useAuth';
|
||||
const store = useMailStore();
|
||||
const { logout } = useAuth();
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "h-screen w-screen overflow-hidden bg-background flex flex-col" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.header, __VLS_intrinsicElements.header)({
|
||||
...{ class: "flex h-14 items-center justify-between border-b px-4" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex items-center gap-2 font-semibold" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex items-center gap-2" },
|
||||
});
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_0 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
...{ 'onClick': {} },
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
}));
|
||||
const __VLS_1 = __VLS_0({
|
||||
...{ 'onClick': {} },
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
|
||||
let __VLS_3;
|
||||
let __VLS_4;
|
||||
let __VLS_5;
|
||||
const __VLS_6 = {
|
||||
onClick: (...[$event]) => {
|
||||
__VLS_ctx.store.isComposeOpen = true;
|
||||
}
|
||||
};
|
||||
__VLS_2.slots.default;
|
||||
var __VLS_2;
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_7 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
...{ 'onClick': {} },
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
}));
|
||||
const __VLS_8 = __VLS_7({
|
||||
...{ 'onClick': {} },
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_7));
|
||||
let __VLS_10;
|
||||
let __VLS_11;
|
||||
let __VLS_12;
|
||||
const __VLS_13 = {
|
||||
onClick: (__VLS_ctx.logout)
|
||||
};
|
||||
__VLS_9.slots.default;
|
||||
var __VLS_9;
|
||||
/** @type {[typeof ResizablePanelGroup, typeof ResizablePanelGroup, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_14 = __VLS_asFunctionalComponent(ResizablePanelGroup, new ResizablePanelGroup({
|
||||
...{ class: "flex-1" },
|
||||
}));
|
||||
const __VLS_15 = __VLS_14({
|
||||
...{ class: "flex-1" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_14));
|
||||
__VLS_16.slots.default;
|
||||
/** @type {[typeof ResizablePanel, typeof ResizablePanel, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_17 = __VLS_asFunctionalComponent(ResizablePanel, new ResizablePanel({
|
||||
defaultSize: (20),
|
||||
minSize: (15),
|
||||
...{ class: "border-r flex flex-col hidden md:flex" },
|
||||
}));
|
||||
const __VLS_18 = __VLS_17({
|
||||
defaultSize: (20),
|
||||
minSize: (15),
|
||||
...{ class: "border-r flex flex-col hidden md:flex" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_17));
|
||||
__VLS_19.slots.default;
|
||||
/** @type {[typeof MailboxSelector, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_20 = __VLS_asFunctionalComponent(MailboxSelector, new MailboxSelector({}));
|
||||
const __VLS_21 = __VLS_20({}, ...__VLS_functionalComponentArgsRest(__VLS_20));
|
||||
/** @type {[typeof FolderNav, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_23 = __VLS_asFunctionalComponent(FolderNav, new FolderNav({
|
||||
...{ 'onSelect': {} },
|
||||
...{ class: "flex-1 overflow-auto" },
|
||||
}));
|
||||
const __VLS_24 = __VLS_23({
|
||||
...{ 'onSelect': {} },
|
||||
...{ class: "flex-1 overflow-auto" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_23));
|
||||
let __VLS_26;
|
||||
let __VLS_27;
|
||||
let __VLS_28;
|
||||
const __VLS_29 = {
|
||||
onSelect: (() => { })
|
||||
};
|
||||
var __VLS_25;
|
||||
var __VLS_19;
|
||||
/** @type {[typeof ResizableHandle, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_30 = __VLS_asFunctionalComponent(ResizableHandle, new ResizableHandle({}));
|
||||
const __VLS_31 = __VLS_30({}, ...__VLS_functionalComponentArgsRest(__VLS_30));
|
||||
/** @type {[typeof ResizablePanel, typeof ResizablePanel, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_33 = __VLS_asFunctionalComponent(ResizablePanel, new ResizablePanel({
|
||||
defaultSize: (35),
|
||||
minSize: (25),
|
||||
...{ class: "border-r flex flex-col hidden sm:flex" },
|
||||
}));
|
||||
const __VLS_34 = __VLS_33({
|
||||
defaultSize: (35),
|
||||
minSize: (25),
|
||||
...{ class: "border-r flex flex-col hidden sm:flex" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_33));
|
||||
__VLS_35.slots.default;
|
||||
/** @type {[typeof SearchBar, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_36 = __VLS_asFunctionalComponent(SearchBar, new SearchBar({}));
|
||||
const __VLS_37 = __VLS_36({}, ...__VLS_functionalComponentArgsRest(__VLS_36));
|
||||
/** @type {[typeof MessageList, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_39 = __VLS_asFunctionalComponent(MessageList, new MessageList({
|
||||
...{ class: "flex-1 overflow-auto" },
|
||||
}));
|
||||
const __VLS_40 = __VLS_39({
|
||||
...{ class: "flex-1 overflow-auto" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_39));
|
||||
var __VLS_35;
|
||||
/** @type {[typeof ResizableHandle, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_42 = __VLS_asFunctionalComponent(ResizableHandle, new ResizableHandle({}));
|
||||
const __VLS_43 = __VLS_42({}, ...__VLS_functionalComponentArgsRest(__VLS_42));
|
||||
/** @type {[typeof ResizablePanel, typeof ResizablePanel, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_45 = __VLS_asFunctionalComponent(ResizablePanel, new ResizablePanel({
|
||||
defaultSize: (45),
|
||||
minSize: (30),
|
||||
...{ class: "flex-1" },
|
||||
}));
|
||||
const __VLS_46 = __VLS_45({
|
||||
defaultSize: (45),
|
||||
minSize: (30),
|
||||
...{ class: "flex-1" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_45));
|
||||
__VLS_47.slots.default;
|
||||
/** @type {[typeof MessageDisplay, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_48 = __VLS_asFunctionalComponent(MessageDisplay, new MessageDisplay({
|
||||
message: (__VLS_ctx.store.currentMessage),
|
||||
}));
|
||||
const __VLS_49 = __VLS_48({
|
||||
message: (__VLS_ctx.store.currentMessage),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_48));
|
||||
var __VLS_47;
|
||||
var __VLS_16;
|
||||
/** @type {[typeof ComposeDialog, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_51 = __VLS_asFunctionalComponent(ComposeDialog, new ComposeDialog({}));
|
||||
const __VLS_52 = __VLS_51({}, ...__VLS_functionalComponentArgsRest(__VLS_51));
|
||||
/** @type {__VLS_StyleScopedClasses['h-screen']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-screen']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['overflow-hidden']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['bg-background']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['h-14']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['justify-between']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-b']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-r']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['hidden']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['md:flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['overflow-auto']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-r']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['hidden']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['sm:flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['overflow-auto']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
ResizablePanelGroup: ResizablePanelGroup,
|
||||
ResizablePanel: ResizablePanel,
|
||||
ResizableHandle: ResizableHandle,
|
||||
FolderNav: FolderNav,
|
||||
MessageList: MessageList,
|
||||
MessageDisplay: MessageDisplay,
|
||||
MailboxSelector: MailboxSelector,
|
||||
SearchBar: SearchBar,
|
||||
ComposeDialog: ComposeDialog,
|
||||
Button: Button,
|
||||
store: store,
|
||||
logout: logout,
|
||||
};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=MailLayout.vue.js.map
|
||||
1
webmail/src/components/mail/MailLayout.vue.js.map
Normal file
1
webmail/src/components/mail/MailLayout.vue.js.map
Normal file
File diff suppressed because one or more lines are too long
21
webmail/src/components/mail/MailboxSelector.vue
Normal file
21
webmail/src/components/mail/MailboxSelector.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const store = useMailStore()
|
||||
|
||||
const currentAddress = computed({
|
||||
get: () => store.currentMailbox,
|
||||
set: (val) => store.currentMailbox = val
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-2 border-b">
|
||||
<select v-model="currentAddress" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option v-for="mb in store.mailboxes" :key="mb.address" :value="mb.address">
|
||||
{{ mb.display_name }} ({{ mb.address }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
58
webmail/src/components/mail/MailboxSelector.vue.js
Normal file
58
webmail/src/components/mail/MailboxSelector.vue.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { useMailStore } from '../../stores/mail';
|
||||
import { computed } from 'vue';
|
||||
const store = useMailStore();
|
||||
const currentAddress = computed({
|
||||
get: () => store.currentMailbox,
|
||||
set: (val) => store.currentMailbox = val
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "px-4 py-2 border-b" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.select, __VLS_intrinsicElements.select)({
|
||||
value: (__VLS_ctx.currentAddress),
|
||||
...{ class: "w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring" },
|
||||
});
|
||||
for (const [mb] of __VLS_getVForSourceType((__VLS_ctx.store.mailboxes))) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({
|
||||
key: (mb.address),
|
||||
value: (mb.address),
|
||||
});
|
||||
(mb.display_name);
|
||||
(mb.address);
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-b']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-input']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['bg-background']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['ring-offset-background']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['focus:outline-none']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['focus:ring-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['focus:ring-ring']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
store: store,
|
||||
currentAddress: currentAddress,
|
||||
};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=MailboxSelector.vue.js.map
|
||||
1
webmail/src/components/mail/MailboxSelector.vue.js.map
Normal file
1
webmail/src/components/mail/MailboxSelector.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"MailboxSelector.vue.js","sourceRoot":"","sources":["MailboxSelector.vue"],"names":[],"mappings":"AAoBW,oFAAoF;AAE/F,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAA;AAE9B,MAAM,KAAK,GAAG,YAAY,EAAE,CAAA;AAE5B,MAAM,cAAc,GAAG,QAAQ,CAAC;IAC9B,GAAG,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,cAAc;IAC/B,GAAG,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,cAAc,GAAG,GAAG;CACzC,CAAC,CAAA;AACF,QAAQ,CAAA,CAAA,yCAAyC;AAIjD,MAAM,SAAS,GAAG,EAAqE,CAAC;AAExF,IAAI,gBAAiE,CAAC;AAEtE,IAAI,gBAAiE,CAAC;AACtE,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC;IACpF,GAAG,EAAE,KAAK,EAAE,oBAAoB,EAAE;CACjC,CAAC,CAAC;AACH,yBAAyB,CAAC,uBAAuB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC;IAC1F,KAAK,EAAE,CAAC,SAAS,CAAC,cAAc,CAAC;IACjC,GAAG,EAAE,KAAK,EAAE,8IAA8I,EAAE;CAC3J,CAAC,CAAC;AACH,KAAK,MAAM,CAAC,EAAE,CAAC,IAAI,uBAAuB,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,SAAS,CAAE,CAAC,EAAE,CAAC;IAC3E,yBAAyB,CAAC,uBAAuB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC;QAC1F,GAAG,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC;QACjB,KAAK,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC;KAClB,CAAC,CAAC;IACH,CAAE,EAAE,CAAC,YAAY,CAAE,CAAC;IACpB,CAAE,EAAE,CAAC,OAAO,CAAE,CAAC;AACf,CAAC;AACD,+CAA+C,CAAA,CAAC;AAChD,+CAA+C,CAAA,CAAC;AAChD,mDAAmD,CAAA,CAAC;AACpD,iDAAiD,CAAA,CAAC;AAClD,qDAAqD,CAAA,CAAC;AACtD,iDAAiD,CAAA,CAAC;AAClD,uDAAuD,CAAA,CAAC;AACxD,wDAAwD,CAAA,CAAC;AACzD,+CAA+C,CAAA,CAAC;AAChD,+CAA+C,CAAA,CAAC;AAChD,kDAAkD,CAAA,CAAC;AACnD,iEAAiE,CAAA,CAAC;AAClE,6DAA6D,CAAA,CAAC;AAC9D,uDAAuD,CAAA,CAAC;AACxD,0DAA0D,CAAA,CAAC;AAO3D,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO;YACP,KAAK,EAAE,KAAqB;YAC5B,cAAc,EAAE,cAAuC;SACtD,CAAC;IACF,CAAC;CACA,CAAC,CAAC;AACH,eAAe,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACrD,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;CACA,CAAC,CAAC;AACH,CAAC,CAAA,kCAAkC"}
|
||||
96
webmail/src/components/mail/MessageDisplay.vue
Normal file
96
webmail/src/components/mail/MessageDisplay.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { format } from 'date-fns'
|
||||
import Avatar from '../ui/Avatar.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Separator from '../ui/Separator.vue'
|
||||
import AttachmentBar from './AttachmentBar.vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { mailApi } from '../../api/mail'
|
||||
|
||||
const props = defineProps({
|
||||
message: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const store = useMailStore()
|
||||
|
||||
const safeHtml = computed(() => {
|
||||
if (!props.message?.html_body) return ''
|
||||
return DOMPurify.sanitize(props.message.html_body)
|
||||
})
|
||||
|
||||
const quotedBody = computed(() => {
|
||||
if (!props.message) return ''
|
||||
const from = props.message.from_address || ''
|
||||
const date = props.message.received_at ? format(new Date(props.message.received_at), 'PPpp') : ''
|
||||
const original = props.message.html_body || `<pre>${props.message.text_body || ''}</pre>`
|
||||
return `<p></p><blockquote style="border-left:2px solid #ccc;padding-left:1em;color:#555;"><p>On ${date}, ${from} wrote:</p>${original}</blockquote>`
|
||||
})
|
||||
|
||||
const reply = () => {
|
||||
if (!props.message) return
|
||||
store.composeDefaults = {
|
||||
to: props.message.from_address,
|
||||
subject: props.message.subject?.startsWith('Re:') ? props.message.subject : `Re: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
||||
const forward = () => {
|
||||
if (!props.message) return
|
||||
store.composeDefaults = {
|
||||
to: '',
|
||||
subject: props.message.subject?.startsWith('Fwd:') ? props.message.subject : `Fwd: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
||||
const trash = async () => {
|
||||
if (!props.message || !store.currentMailbox) return
|
||||
try {
|
||||
await mailApi.deleteMessage(store.currentMailbox, props.message.id)
|
||||
store.messages = store.messages.filter((m: any) => m.id !== props.message!.id)
|
||||
store.currentMessage = null
|
||||
} catch (e) {
|
||||
console.error('Failed to trash message', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" class="flex h-full flex-col">
|
||||
<div class="flex items-start p-4">
|
||||
<div class="flex items-start gap-4 text-sm">
|
||||
<Avatar :initials="message.from_name?.[0] || message.from_address?.[0] || '?'" />
|
||||
<div class="grid gap-1">
|
||||
<div class="font-semibold">{{ message.from_name }}</div>
|
||||
<div class="line-clamp-1 text-xs">{{ message.subject }}</div>
|
||||
<div class="line-clamp-1 text-xs">
|
||||
<span class="font-medium">From:</span> {{ message.from_address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.received_at" class="ml-auto text-xs text-muted-foreground">
|
||||
{{ format(new Date(message.received_at), 'PPpp') }}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex-1 overflow-y-auto p-4 text-sm">
|
||||
<div v-if="message.html_body" v-html="safeHtml" class="prose max-w-none dark:prose-invert"></div>
|
||||
<div v-else class="whitespace-pre-wrap">{{ message.text_body }}</div>
|
||||
</div>
|
||||
<AttachmentBar :attachments="message.attachments" />
|
||||
<Separator />
|
||||
<div class="p-4 flex gap-2">
|
||||
<Button @click="reply">Reply</Button>
|
||||
<Button variant="outline" @click="forward">Forward</Button>
|
||||
<Button variant="destructive" @click="trash">Trash</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-full items-center justify-center p-8 text-muted-foreground">
|
||||
No message selected
|
||||
</div>
|
||||
</template>
|
||||
192
webmail/src/components/mail/MessageDisplay.vue.js
Normal file
192
webmail/src/components/mail/MessageDisplay.vue.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { computed } from 'vue';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { format } from 'date-fns';
|
||||
import Avatar from '../ui/Avatar.vue';
|
||||
import Button from '../ui/Button.vue';
|
||||
import Separator from '../ui/Separator.vue';
|
||||
import AttachmentBar from './AttachmentBar.vue';
|
||||
const props = defineProps({
|
||||
message: { type: Object, default: null }
|
||||
});
|
||||
const safeHtml = computed(() => {
|
||||
if (!props.message?.html_body)
|
||||
return '';
|
||||
return DOMPurify.sanitize(props.message.html_body);
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
if (__VLS_ctx.message) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex h-full flex-col" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex items-start p-4" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex items-start gap-4 text-sm" },
|
||||
});
|
||||
/** @type {[typeof Avatar, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_0 = __VLS_asFunctionalComponent(Avatar, new Avatar({
|
||||
initials: (__VLS_ctx.message.from_name?.[0] || __VLS_ctx.message.from_address?.[0] || '?'),
|
||||
}));
|
||||
const __VLS_1 = __VLS_0({
|
||||
initials: (__VLS_ctx.message.from_name?.[0] || __VLS_ctx.message.from_address?.[0] || '?'),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "grid gap-1" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "font-semibold" },
|
||||
});
|
||||
(__VLS_ctx.message.from_name);
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "line-clamp-1 text-xs" },
|
||||
});
|
||||
(__VLS_ctx.message.subject);
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "line-clamp-1 text-xs" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
|
||||
...{ class: "font-medium" },
|
||||
});
|
||||
(__VLS_ctx.message.from_address);
|
||||
if (__VLS_ctx.message.received_at) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "ml-auto text-xs text-muted-foreground" },
|
||||
});
|
||||
(__VLS_ctx.format(new Date(__VLS_ctx.message.received_at), 'PPpp'));
|
||||
}
|
||||
/** @type {[typeof Separator, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_3 = __VLS_asFunctionalComponent(Separator, new Separator({}));
|
||||
const __VLS_4 = __VLS_3({}, ...__VLS_functionalComponentArgsRest(__VLS_3));
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex-1 overflow-y-auto p-4 text-sm" },
|
||||
});
|
||||
if (__VLS_ctx.message.html_body) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "prose max-w-none dark:prose-invert" },
|
||||
});
|
||||
__VLS_asFunctionalDirective(__VLS_directives.vHtml)(null, { ...__VLS_directiveBindingRestFields, value: (__VLS_ctx.safeHtml) }, null, null);
|
||||
}
|
||||
else {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "whitespace-pre-wrap" },
|
||||
});
|
||||
(__VLS_ctx.message.text_body);
|
||||
}
|
||||
/** @type {[typeof AttachmentBar, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_6 = __VLS_asFunctionalComponent(AttachmentBar, new AttachmentBar({
|
||||
attachments: (__VLS_ctx.message.attachments),
|
||||
}));
|
||||
const __VLS_7 = __VLS_6({
|
||||
attachments: (__VLS_ctx.message.attachments),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_6));
|
||||
/** @type {[typeof Separator, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_9 = __VLS_asFunctionalComponent(Separator, new Separator({}));
|
||||
const __VLS_10 = __VLS_9({}, ...__VLS_functionalComponentArgsRest(__VLS_9));
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "p-4 flex gap-2" },
|
||||
});
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_12 = __VLS_asFunctionalComponent(Button, new Button({}));
|
||||
const __VLS_13 = __VLS_12({}, ...__VLS_functionalComponentArgsRest(__VLS_12));
|
||||
__VLS_14.slots.default;
|
||||
var __VLS_14;
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_15 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
variant: "outline",
|
||||
}));
|
||||
const __VLS_16 = __VLS_15({
|
||||
variant: "outline",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_15));
|
||||
__VLS_17.slots.default;
|
||||
var __VLS_17;
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_18 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
variant: "destructive",
|
||||
}));
|
||||
const __VLS_19 = __VLS_18({
|
||||
variant: "destructive",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_18));
|
||||
__VLS_20.slots.default;
|
||||
var __VLS_20;
|
||||
}
|
||||
else {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex h-full items-center justify-center p-8 text-muted-foreground" },
|
||||
});
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['h-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-start']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-start']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['grid']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['line-clamp-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['line-clamp-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['ml-auto']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['overflow-y-auto']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['prose']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['max-w-none']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['dark:prose-invert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['whitespace-pre-wrap']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['h-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-8']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
format: format,
|
||||
Avatar: Avatar,
|
||||
Button: Button,
|
||||
Separator: Separator,
|
||||
AttachmentBar: AttachmentBar,
|
||||
safeHtml: safeHtml,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
message: { type: Object, default: null }
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
message: { type: Object, default: null }
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=MessageDisplay.vue.js.map
|
||||
1
webmail/src/components/mail/MessageDisplay.vue.js.map
Normal file
1
webmail/src/components/mail/MessageDisplay.vue.js.map
Normal file
File diff suppressed because one or more lines are too long
19
webmail/src/components/mail/MessageList.vue
Normal file
19
webmail/src/components/mail/MessageList.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import MessageListItem from './MessageListItem.vue'
|
||||
|
||||
const store = useMailStore()
|
||||
|
||||
const selectMessage = (msg: any) => {
|
||||
store.currentMessage = msg
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<MessageListItem v-for="msg in store.messages" :key="msg.id" :message="msg" :selected="store.currentMessage?.id === msg.id" @click="selectMessage(msg)" />
|
||||
<div v-if="store.messages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No messages found.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
webmail/src/components/mail/MessageList.vue.js
Normal file
69
webmail/src/components/mail/MessageList.vue.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { useMailStore } from '../../stores/mail';
|
||||
import MessageListItem from './MessageListItem.vue';
|
||||
const store = useMailStore();
|
||||
const selectMessage = (msg) => {
|
||||
store.currentMessage = msg;
|
||||
};
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex flex-col gap-2 p-4 pt-0" },
|
||||
});
|
||||
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.store.messages))) {
|
||||
/** @type {[typeof MessageListItem, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_0 = __VLS_asFunctionalComponent(MessageListItem, new MessageListItem({
|
||||
...{ 'onClick': {} },
|
||||
key: (msg.id),
|
||||
message: (msg),
|
||||
selected: (__VLS_ctx.store.currentMessage?.id === msg.id),
|
||||
}));
|
||||
const __VLS_1 = __VLS_0({
|
||||
...{ 'onClick': {} },
|
||||
key: (msg.id),
|
||||
message: (msg),
|
||||
selected: (__VLS_ctx.store.currentMessage?.id === msg.id),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
|
||||
let __VLS_3;
|
||||
let __VLS_4;
|
||||
let __VLS_5;
|
||||
const __VLS_6 = {
|
||||
onClick: (...[$event]) => {
|
||||
__VLS_ctx.selectMessage(msg);
|
||||
}
|
||||
};
|
||||
var __VLS_2;
|
||||
}
|
||||
if (__VLS_ctx.store.messages.length === 0) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "p-8 text-center text-muted-foreground" },
|
||||
});
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['pt-0']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-8']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
MessageListItem: MessageListItem,
|
||||
store: store,
|
||||
selectMessage: selectMessage,
|
||||
};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=MessageList.vue.js.map
|
||||
1
webmail/src/components/mail/MessageList.vue.js.map
Normal file
1
webmail/src/components/mail/MessageList.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"MessageList.vue.js","sourceRoot":"","sources":["MessageList.vue"],"names":[],"mappings":"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"}
|
||||
29
webmail/src/components/mail/MessageListItem.vue
Normal file
29
webmail/src/components/mail/MessageListItem.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import Badge from '../ui/Badge.vue'
|
||||
|
||||
defineProps({
|
||||
message: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="['flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent', selected ? 'bg-muted' : 'bg-background']">
|
||||
<div class="flex w-full flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold">{{ message.from_name || message.from_address }}</div>
|
||||
<div class="text-xs text-muted-foreground" v-if="message.received_at">
|
||||
{{ formatDistanceToNow(new Date(message.received_at), { addSuffix: true }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-medium">{{ message.subject }}</div>
|
||||
</div>
|
||||
<div class="line-clamp-2 text-xs text-muted-foreground">
|
||||
{{ message.text_body?.substring(0, 100) || 'No content' }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2" v-if="message.has_attachments">
|
||||
<Badge variant="secondary">Attachment</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
94
webmail/src/components/mail/MessageListItem.vue.js
Normal file
94
webmail/src/components/mail/MessageListItem.vue.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import Badge from '../ui/Badge.vue';
|
||||
const __VLS_props = defineProps({
|
||||
message: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
|
||||
...{ class: (['flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent', __VLS_ctx.selected ? 'bg-muted' : 'bg-background']) },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex w-full flex-col gap-1" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex items-center justify-between" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "font-semibold" },
|
||||
});
|
||||
(__VLS_ctx.message.from_name || __VLS_ctx.message.from_address);
|
||||
if (__VLS_ctx.message.received_at) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "text-xs text-muted-foreground" },
|
||||
});
|
||||
(__VLS_ctx.formatDistanceToNow(new Date(__VLS_ctx.message.received_at), { addSuffix: true }));
|
||||
}
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "font-medium" },
|
||||
});
|
||||
(__VLS_ctx.message.subject);
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "line-clamp-2 text-xs text-muted-foreground" },
|
||||
});
|
||||
(__VLS_ctx.message.text_body?.substring(0, 100) || 'No content');
|
||||
if (__VLS_ctx.message.has_attachments) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex items-center gap-2" },
|
||||
});
|
||||
/** @type {[typeof Badge, typeof Badge, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_0 = __VLS_asFunctionalComponent(Badge, new Badge({
|
||||
variant: "secondary",
|
||||
}));
|
||||
const __VLS_1 = __VLS_0({
|
||||
variant: "secondary",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
|
||||
__VLS_2.slots.default;
|
||||
var __VLS_2;
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['justify-between']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['line-clamp-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
formatDistanceToNow: formatDistanceToNow,
|
||||
Badge: Badge,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
message: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false }
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
message: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false }
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=MessageListItem.vue.js.map
|
||||
1
webmail/src/components/mail/MessageListItem.vue.js.map
Normal file
1
webmail/src/components/mail/MessageListItem.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"MessageListItem.vue.js","sourceRoot":"","sources":["MessageListItem.vue"],"names":[],"mappings":"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"}
|
||||
38
webmail/src/components/mail/SearchBar.vue
Normal file
38
webmail/src/components/mail/SearchBar.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useSearch } from '../../composables/useSearch'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
|
||||
const searchVal = ref('')
|
||||
const { search, results, loading } = useSearch()
|
||||
const store = useMailStore()
|
||||
|
||||
let timeout: any
|
||||
|
||||
watch(searchVal, (val) => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
if (store.currentMailbox) search(store.currentMailbox, val)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
const selectResult = (msg: any) => {
|
||||
store.currentMessage = msg
|
||||
searchVal.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full px-4 py-2 border-b">
|
||||
<input v-model="searchVal" type="search" placeholder="Search..." class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<div v-if="searchVal && results.length > 0" class="absolute left-0 right-0 top-full z-10 mt-1 max-h-[300px] overflow-y-auto rounded-md border bg-background p-1 shadow-md">
|
||||
<div v-for="res in results" :key="res.id" @click="selectResult(res)" class="cursor-pointer rounded-sm px-2 py-1 text-sm hover:bg-accent">
|
||||
<div class="font-semibold">{{ res.subject }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ res.from_address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="searchVal && !loading" class="absolute left-0 right-0 top-full z-10 mt-1 rounded-md border bg-background p-4 text-center text-sm text-muted-foreground shadow-md">
|
||||
No results found.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
131
webmail/src/components/mail/SearchBar.vue.js
Normal file
131
webmail/src/components/mail/SearchBar.vue.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { ref, watch } from 'vue';
|
||||
import { useSearch } from '../../composables/useSearch';
|
||||
import { useMailStore } from '../../stores/mail';
|
||||
const searchVal = ref('');
|
||||
const { search, results, loading } = useSearch();
|
||||
const store = useMailStore();
|
||||
let timeout;
|
||||
watch(searchVal, (val) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
if (store.currentMailbox)
|
||||
search(store.currentMailbox, val);
|
||||
}, 300);
|
||||
});
|
||||
const selectResult = (msg) => {
|
||||
store.currentMessage = msg;
|
||||
searchVal.value = '';
|
||||
};
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "relative w-full px-4 py-2 border-b" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
|
||||
type: "search",
|
||||
placeholder: "Search...",
|
||||
...{ class: "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" },
|
||||
});
|
||||
(__VLS_ctx.searchVal);
|
||||
if (__VLS_ctx.searchVal && __VLS_ctx.results.length > 0) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "absolute left-0 right-0 top-full z-10 mt-1 max-h-[300px] overflow-y-auto rounded-md border bg-background p-1 shadow-md" },
|
||||
});
|
||||
for (const [res] of __VLS_getVForSourceType((__VLS_ctx.results))) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
if (!(__VLS_ctx.searchVal && __VLS_ctx.results.length > 0))
|
||||
return;
|
||||
__VLS_ctx.selectResult(res);
|
||||
} },
|
||||
key: (res.id),
|
||||
...{ class: "cursor-pointer rounded-sm px-2 py-1 text-sm hover:bg-accent" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "font-semibold" },
|
||||
});
|
||||
(res.subject);
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "text-xs text-muted-foreground" },
|
||||
});
|
||||
(res.from_address);
|
||||
}
|
||||
}
|
||||
else if (__VLS_ctx.searchVal && !__VLS_ctx.loading) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "absolute left-0 right-0 top-full z-10 mt-1 rounded-md border bg-background p-4 text-center text-sm text-muted-foreground shadow-md" },
|
||||
});
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['relative']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-b']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-input']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['bg-background']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['focus:outline-none']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['focus:ring-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['focus:ring-ring']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['absolute']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['left-0']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['right-0']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['top-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['z-10']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['mt-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['max-h-[300px]']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['overflow-y-auto']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['bg-background']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['cursor-pointer']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['py-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['absolute']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['left-0']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['right-0']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['top-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['z-10']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['mt-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['bg-background']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
searchVal: searchVal,
|
||||
results: results,
|
||||
loading: loading,
|
||||
selectResult: selectResult,
|
||||
};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=SearchBar.vue.js.map
|
||||
1
webmail/src/components/mail/SearchBar.vue.js.map
Normal file
1
webmail/src/components/mail/SearchBar.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"SearchBar.vue.js","sourceRoot":"","sources":["SearchBar.vue"],"names":[],"mappings":"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"}
|
||||
14
webmail/src/components/ui/Avatar.vue
Normal file
14
webmail/src/components/ui/Avatar.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
src: { type: String, default: '' },
|
||||
initials: { type: String, default: '' },
|
||||
class: { type: String, default: '' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full bg-muted', props.class]">
|
||||
<img v-if="src" :src="src" class="aspect-square h-full w-full" />
|
||||
<span v-else class="flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground">{{ initials }}</span>
|
||||
</span>
|
||||
</template>
|
||||
59
webmail/src/components/ui/Avatar.vue.js
Normal file
59
webmail/src/components/ui/Avatar.vue.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
const props = defineProps({
|
||||
src: { type: String, default: '' },
|
||||
initials: { type: String, default: '' },
|
||||
class: { type: String, default: '' }
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
|
||||
...{ class: (['relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full bg-muted', props.class]) },
|
||||
});
|
||||
if (__VLS_ctx.src) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.img)({
|
||||
src: (__VLS_ctx.src),
|
||||
...{ class: "aspect-square h-full w-full" },
|
||||
});
|
||||
}
|
||||
else {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
|
||||
...{ class: "flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground" },
|
||||
});
|
||||
(__VLS_ctx.initials);
|
||||
}
|
||||
/** @type {__VLS_StyleScopedClasses['aspect-square']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['h-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['h-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['rounded-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['bg-muted']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
src: { type: String, default: '' },
|
||||
initials: { type: String, default: '' },
|
||||
class: { type: String, default: '' }
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
src: { type: String, default: '' },
|
||||
initials: { type: String, default: '' },
|
||||
class: { type: String, default: '' }
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=Avatar.vue.js.map
|
||||
1
webmail/src/components/ui/Avatar.vue.js.map
Normal file
1
webmail/src/components/ui/Avatar.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"Avatar.vue.js","sourceRoot":"","sources":["Avatar.vue"],"names":[],"mappings":"AAaW,oFAAoF;AAE/F,MAAM,KAAK,GAAG,WAAW,CAAC;IACxB,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;IAClC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;IACvC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;CACrC,CAAC,CAAA;AACF,QAAQ,CAAA,CAAA,yCAAyC;AAIjD,MAAM,SAAS,GAAG,EAAqE,CAAC;AAExF,IAAI,gBAAiE,CAAC;AAEtE,IAAI,gBAAiE,CAAC;AACtE,yBAAyB,CAAC,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC;IACtF,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,wEAAwE,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE;CACtG,CAAC,CAAC;AACH,IAAI,SAAS,CAAC,GAAG,EAAE,CAAC;IACpB,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,CAAC,CAAC;QACvD,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC;QACpB,GAAG,EAAE,KAAK,EAAE,6BAA6B,EAAE;KAC1C,CAAC,CAAC;AACH,CAAC;KACI,CAAC;IACN,yBAAyB,CAAC,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC;QACtF,GAAG,EAAE,KAAK,EAAE,4FAA4F,EAAE;KACzG,CAAC,CAAC;IACH,CAAE,SAAS,CAAC,QAAQ,CAAE,CAAC;AACvB,CAAC;AACD,wDAAwD,CAAA,CAAC;AACzD,iDAAiD,CAAA,CAAC;AAClD,iDAAiD,CAAA,CAAC;AAClD,+CAA+C,CAAA,CAAC;AAChD,iDAAiD,CAAA,CAAC;AAClD,iDAAiD,CAAA,CAAC;AAClD,uDAAuD,CAAA,CAAC;AACxD,yDAAyD,CAAA,CAAC;AAC1D,uDAAuD,CAAA,CAAC;AACxD,mDAAmD,CAAA,CAAC;AACpD,gEAAgE,CAAA,CAAC;AAOjE,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;IACD,KAAK,EAAE;QACL,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;QAClC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;QACvC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;KACrC;CACA,CAAC,CAAC;AACH,eAAe,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACrD,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;IACD,KAAK,EAAE;QACL,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;QAClC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;QACvC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;KACrC;CACA,CAAC,CAAC;AACH,CAAC,CAAA,kCAAkC"}
|
||||
25
webmail/src/components/ui/Badge.vue
Normal file
25
webmail/src/components/ui/Badge.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const variants = {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
}
|
||||
|
||||
const computedClass = computed(() => {
|
||||
return `inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${variants[props.variant as keyof typeof variants] || variants.default} ${props.class}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="computedClass">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
49
webmail/src/components/ui/Badge.vue.js
Normal file
49
webmail/src/components/ui/Badge.vue.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' }
|
||||
});
|
||||
const variants = {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
};
|
||||
const computedClass = computed(() => {
|
||||
return `inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${variants[props.variant] || variants.default} ${props.class}`;
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: (__VLS_ctx.computedClass) },
|
||||
});
|
||||
var __VLS_0 = {};
|
||||
// @ts-ignore
|
||||
var __VLS_1 = __VLS_0;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
computedClass: computedClass,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
variant: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' }
|
||||
},
|
||||
});
|
||||
const __VLS_component = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
variant: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' }
|
||||
},
|
||||
});
|
||||
export default {};
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=Badge.vue.js.map
|
||||
1
webmail/src/components/ui/Badge.vue.js.map
Normal file
1
webmail/src/components/ui/Badge.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"Badge.vue.js","sourceRoot":"","sources":["Badge.vue"],"names":[],"mappings":"AAwBW,oFAAoF;AAE/F,OAAO,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAA;AAE9B,MAAM,KAAK,GAAG,WAAW,CAAC;IACxB,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE;IAC7C,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;CACrC,CAAC,CAAA;AAEF,MAAM,QAAQ,GAAG;IACf,OAAO,EAAE,2EAA2E;IACpF,SAAS,EAAE,iFAAiF;IAC5F,WAAW,EAAE,uFAAuF;IACpG,OAAO,EAAE,iBAAiB;CAC3B,CAAA;AAED,MAAM,aAAa,GAAG,QAAQ,CAAC,GAAG,EAAE;IAClC,OAAO,0KAA0K,QAAQ,CAAC,KAAK,CAAC,OAAgC,CAAC,IAAI,QAAQ,CAAC,OAAO,IAAI,KAAK,CAAC,KAAK,EAAE,CAAA;AACxQ,CAAC,CAAC,CAAA;AACF,QAAQ,CAAA,CAAA,yCAAyC;AAIjD,MAAM,SAAS,GAAG,EAAqE,CAAC;AAExF,IAAI,gBAAiE,CAAC;AAEtE,IAAI,gBAAiE,CAAC;AACtE,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC;IACpF,GAAG,EAAE,KAAK,EAAE,CAAC,SAAS,CAAC,aAAa,CAAC,EAAE;CACtC,CAAC,CAAC;AACH,IAAI,OAAO,GAAG,EACb,CAAC;AACF,aAAa;AACb,IAAI,OAAO,GAAG,OAAQ,CAAE;AAQxB,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO;YACP,aAAa,EAAE,aAAqC;SACnD,CAAC;IACF,CAAC;IACD,KAAK,EAAE;QACL,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE;QAC7C,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;KACrC;CACA,CAAC,CAAC;AACH,MAAM,eAAe,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IAC9D,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;IACD,KAAK,EAAE;QACL,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE;QAC7C,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;KACrC;CACA,CAAC,CAAC;AACH,eAAe,EAA0D,CAAC;AAC1E,CAAC,CAAA,kCAAkC"}
|
||||
39
webmail/src/components/ui/Button.vue
Normal file
39
webmail/src/components/ui/Button.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'default' },
|
||||
size: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
as: { type: String, default: 'button' }
|
||||
})
|
||||
|
||||
const baseClass = "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
|
||||
const variants = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
}
|
||||
|
||||
const computedClass = computed(() => {
|
||||
return `${baseClass} ${variants[props.variant as keyof typeof variants] || variants.default} ${sizes[props.size as keyof typeof sizes] || sizes.default} ${props.class}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="as" :class="computedClass" :disabled="as === 'button' ? disabled : undefined" v-bind="$attrs">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
65
webmail/src/components/ui/Button.vue.js
Normal file
65
webmail/src/components/ui/Button.vue.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'default' },
|
||||
size: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
});
|
||||
const baseClass = "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
|
||||
const variants = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
};
|
||||
const sizes = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
};
|
||||
const computedClass = computed(() => {
|
||||
return `${baseClass} ${variants[props.variant] || variants.default} ${sizes[props.size] || sizes.default} ${props.class}`;
|
||||
});
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
|
||||
...{ class: (__VLS_ctx.computedClass) },
|
||||
disabled: (__VLS_ctx.disabled),
|
||||
});
|
||||
var __VLS_0 = {};
|
||||
// @ts-ignore
|
||||
var __VLS_1 = __VLS_0;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
computedClass: computedClass,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
variant: { type: String, default: 'default' },
|
||||
size: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
});
|
||||
const __VLS_component = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
variant: { type: String, default: 'default' },
|
||||
size: { type: String, default: 'default' },
|
||||
class: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
});
|
||||
export default {};
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
//# sourceMappingURL=Button.vue.js.map
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue