mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agente",
|
||||
"nav.settings": "Iistellige",
|
||||
"nav.help": "Hilf",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Abbräche",
|
||||
"common.close": "Schliesse",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Absände",
|
||||
"common.none": "Keis",
|
||||
"common.not_set": "Nid gsetzt",
|
||||
|
||||
"login.title": "Amälde - DockFlare",
|
||||
"login.username_placeholder": "Benutzername",
|
||||
"login.password_placeholder": "Passwort",
|
||||
"login.submit": "Aamälde",
|
||||
"login.sign_in_with": "Aamälde mit {provider}",
|
||||
|
||||
"help.title": "Hilf - {title}",
|
||||
|
||||
"restore.title": "DockFlare start neu",
|
||||
"restore.hold_tight": "E chly Geduld, DockFlare start grad neu...",
|
||||
"restore.flavor_text": "Mir lade dini widerhärgstellti Konfiguration u gäbe de Tunnel-Hamster no es churzes Pep-Talk.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "Verschlüssleti Secrets sy erfolgrych importiert worde.",
|
||||
"restore.agents_warming_up": "Agente u Regle wärme grad uf.",
|
||||
"restore.refresh_in": "Die Syte wird i <span id=\"countdown\">{seconds}</span> Sekunde automatisch neu glade.",
|
||||
|
||||
"status.title": "Übersicht",
|
||||
"status.initialization_in_progress": "Initialisierig louft...",
|
||||
"status.init_logs_below": "D Log chasch dunde aluege. D Oberflächi wird aktualisiert, sobald si parat isch.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Gib e Hostname ii, zum d Cloudflare-Zone automatisch z erkenne. Wänn mehri Treffer gfunde wärde, chasch e Zone uswähle.",
|
||||
"status.filter_sort_options": "Filter- u Sortier-Optione",
|
||||
"status.showing_rules": "{visible} vo {total} Regle wärde azeigt",
|
||||
|
||||
"settings.title": "Iistellige",
|
||||
"settings.general_settings": "Allgmeini Iistellige",
|
||||
"settings.all_cloudflare_tunnels": "Aui Cloudflare-Tunnel",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "z.B. my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "DockFlare öffentlechi URL",
|
||||
"settings.dockflare_public_url_help": "Wird für d Generierig vo Agent-Deploy-Skripts u für s Scoping vo Cloudflare Zero Trust Apps bruucht. D Umgebigsvariable DOCKFLARE_PUBLIC_URL het Vorrang, wänn si gsetzt isch.",
|
||||
|
||||
"policies.title": "Zuegriffsrichtlinie",
|
||||
"policies.advanced_access_policies": "Erwytereti Zuegriffsrichtlinie",
|
||||
"policies.create_reusable_desc": "Erstell wiederverwendbari Zuegriffsrichtlinie, wo sich mit eme einzelne Label aawände löh.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Das Element entferne",
|
||||
"policies.search_select_countries": "Länder sueche u zum Sperre uswähle...",
|
||||
"policies.select_identity_providers": "ID-Provider uswähle...",
|
||||
|
||||
"agents.title": "Agent-Verwaltig",
|
||||
"agents.agents_management": "Agent-Verwaltig",
|
||||
"agents.force_reconciliation": "Abglich erzwinge",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Compose-Schnipsel",
|
||||
"agents.deploy_quick_desc": "Kopier das Skript u füeg es direkt i dini SSH-Sitzung uf em Ziel-Server ii.",
|
||||
"agents.deploy_compose_desc": "Als <code>docker-compose.yml</code> speichere, sicherstelle dass s <code>cloudflare-net</code>-Netzwerk existiert, denn <code>docker compose up -d</code> usfüehre.",
|
||||
|
||||
"setup.title": "DockFlare-Iirichtig",
|
||||
"setup.step1.create_admin": "Admin-Benutzer erstelle",
|
||||
"setup.step1.final_step": "Letschte Schritt: Admin-Benutzer erstelle",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "Bitte prüef d importierte Iistellige. Wänn alles stimmt, gah wyter zum letschte Schritt: dr Admin-Benutzer z erstelle.",
|
||||
"setup.import.proceed": "Mit dr Migration wyterfahre",
|
||||
"setup.import.cancel": "Nöii Konfiguration erstelle",
|
||||
|
||||
"modal.access_group.title_create": "Nöii Zuegriffsgruppe erstelle",
|
||||
"modal.access_group.title_edit": "Zuegriffsgruppe bearbeite",
|
||||
"modal.access_group.tab_authenticated": "Authentifizierte Zuegriff",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Automatisch zur Identität wyterleite",
|
||||
"modal.access_group.app_launcher_visible": "Im App-Launcher sichtbar",
|
||||
"modal.access_group.save_group": "Gruppe speichere",
|
||||
|
||||
"modal.idp.title_create": "ID-Provider derzue tue",
|
||||
"modal.idp.title_edit": "ID-Provider bearbeite",
|
||||
"modal.idp.help_text": "Bruchsch Hilf? Lueg i",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "Redirect-URI für d OAuth-Konfiguration:",
|
||||
"modal.idp.create_provider": "Provider erstelle",
|
||||
"modal.idp.save_provider": "Provider speichere",
|
||||
|
||||
"js.alert.edit_dialog_error": "Dr Bearbeitigsdialog het wäge eme Fähler nid chönne ufgaa. Bitte Konsole prüefe.",
|
||||
"js.alert.sync_error": "Fähler: {error}",
|
||||
"js.alert.sync_error_title": "Synchronisierigsfähler",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "Vom Agent",
|
||||
"js.prompt.delete_tunnel_confirm": "Gib \"delete\" ii, zum d Tunnel-Löschig z bestätige:",
|
||||
"js.prompt.rename_agent": "Gib e nöie Aazeigename für dä Agent ii:",
|
||||
|
||||
"flash.general_settings_updated": "Allgmeini Iistellige erfolgrych aktualisiert.",
|
||||
"flash.tunnel_name_changed": "Tunnelname gänderet. Dr Agent wird neu gstartet, zum d Änderige z übernäh...",
|
||||
"flash.error_saving_settings": "Biim Speichere vo de Iistellige isch e Fähler passiert.",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Iistellige bestätigt. Bitte erstell e Admin-Benutzer, zum wyterz'mache.",
|
||||
"flash.setup.required_fields_missing": "Warnig: Pflichtfälder (CF_API_TOKEN oder CF_ACCOUNT_ID) fähle. Du chasch nid wyterfahre.",
|
||||
"flash.setup.setup_complete": "Iirichtig abgeschlossen! Bitte mäud di aa, zum wyterz'mache.",
|
||||
|
||||
"form.setup.username": "Benutzername",
|
||||
"form.setup.password": "Passwort",
|
||||
"form.setup.confirm_password": "Passwort bestätige",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "D Account-ID muess 32 Zeiche lang sy.",
|
||||
"form.cloudflare.api_token": "Cloudflare-API-Token",
|
||||
"form.cloudflare.api_token_length": "Dr API-Token muess 40 Zeiche lang sy.",
|
||||
"form.cloudflare.submit": "Cloudflare-Zuegangsdaten aktualisiere"
|
||||
"form.cloudflare.submit": "Cloudflare-Zuegangsdaten aktualisiere",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agents",
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.help": "Hilfe",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.close": "Schließen",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Absenden",
|
||||
"common.none": "Keine",
|
||||
"common.not_set": "Nicht gesetzt",
|
||||
|
||||
"login.title": "Anmelden - DockFlare",
|
||||
"login.username_placeholder": "Benutzername",
|
||||
"login.password_placeholder": "Passwort",
|
||||
"login.submit": "Anmelden",
|
||||
"login.sign_in_with": "Anmelden mit {provider}",
|
||||
|
||||
"help.title": "Hilfe - {title}",
|
||||
|
||||
"restore.title": "DockFlare wird neu gestartet",
|
||||
"restore.hold_tight": "Einen Moment bitte, DockFlare wird neu gestartet...",
|
||||
"restore.flavor_text": "Ihre wiederhergestellte Konfiguration wird geladen und die Tunnel-Hamster werden kurz motiviert.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "Verschlüsselte Secrets wurden erfolgreich importiert.",
|
||||
"restore.agents_warming_up": "Agents und Regeln werden initialisiert.",
|
||||
"restore.refresh_in": "Diese Seite wird automatisch in <span id=\"countdown\">{seconds}</span> Sekunden neu geladen.",
|
||||
|
||||
"status.title": "Dashboard",
|
||||
"status.initialization_in_progress": "Initialisierung läuft...",
|
||||
"status.init_logs_below": "Sie können die Protokolle unten einsehen. Die Oberfläche wird aktualisiert, sobald sie bereit ist.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Geben Sie einen Hostnamen ein, um die Cloudflare-Zone automatisch zu erkennen. Wählen Sie eine Zone aus, wenn mehrere Treffer gefunden werden.",
|
||||
"status.filter_sort_options": "Filter- und Sortieroptionen",
|
||||
"status.showing_rules": "{visible} von {total} Regeln werden angezeigt",
|
||||
|
||||
"settings.title": "Einstellungen",
|
||||
"settings.general_settings": "Allgemeine Einstellungen",
|
||||
"settings.all_cloudflare_tunnels": "Alle Cloudflare-Tunnel",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "z.B. my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "Öffentliche DockFlare-URL",
|
||||
"settings.dockflare_public_url_help": "Wird für die Generierung von Agent-Bereitstellungsskripten und die Eingrenzung der Cloudflare Zero Trust-App verwendet. Die Umgebungsvariable DOCKFLARE_PUBLIC_URL hat Vorrang, wenn sie gesetzt ist.",
|
||||
|
||||
"policies.title": "Zugriffsrichtlinien",
|
||||
"policies.advanced_access_policies": "Erweiterte Zugriffsrichtlinien",
|
||||
"policies.create_reusable_desc": "Erstellen Sie wiederverwendbare Zugriffsrichtlinien, die mit einem einzelnen Label angewendet werden können.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Dieses Element entfernen",
|
||||
"policies.search_select_countries": "Länder suchen und zum Sperren auswählen...",
|
||||
"policies.select_identity_providers": "Identitätsanbieter auswählen...",
|
||||
|
||||
"agents.title": "Agent-Verwaltung",
|
||||
"agents.agents_management": "Agent-Verwaltung",
|
||||
"agents.force_reconciliation": "Abstimmung erzwingen",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Compose-Snippet",
|
||||
"agents.deploy_quick_desc": "Kopieren Sie dieses Skript und fügen Sie es direkt in Ihre SSH-Sitzung auf dem Zielserver ein.",
|
||||
"agents.deploy_compose_desc": "Speichern als <code>docker-compose.yml</code>, sicherstellen, dass das Netzwerk <code>cloudflare-net</code> vorhanden ist, dann <code>docker compose up -d</code> ausführen.",
|
||||
|
||||
"setup.title": "DockFlare-Einrichtung",
|
||||
"setup.step1.create_admin": "Administrator erstellen",
|
||||
"setup.step1.final_step": "Letzter Schritt: Administrator erstellen",
|
||||
|
|
@ -421,17 +412,14 @@
|
|||
"setup.step1.desc_migration": "Ihre Einstellungen wurden importiert. Bitte erstellen Sie ein Administratorkonto, um die Migration abzuschließen.",
|
||||
"setup.step1.username_placeholder": "z.B. admin",
|
||||
"setup.step1.restore_option": "Von einer anderen DockFlare-Instanz migrieren? Aus Sicherung wiederherstellen",
|
||||
|
||||
"setup.steps.step1": "Webzugriff",
|
||||
"setup.steps.step2": "Cloudflare",
|
||||
"setup.steps.step3": "Tunnel",
|
||||
"setup.steps.step4": "Abschließen",
|
||||
|
||||
"setup.step2.desc": "Geben Sie Ihren Cloudflare-API-Token und Ihre Konto-ID an. Dies ist erforderlich, damit DockFlare Ihre Tunnel und DNS-Einträge sicher verwalten kann. Ihre Konto-ID finden Sie im Cloudflare-Dashboard auf der rechten Seite der Übersichtsseite einer Ihrer Domains. Ein API-Token kann auf der Seite \"API-Tokens\" in Ihrem Profil erstellt werden.",
|
||||
"setup.step2.token_placeholder": "Ihr Cloudflare-API-Token",
|
||||
"setup.step2.account_id_placeholder": "Ihre Cloudflare-Konto-ID",
|
||||
"setup.step2.back": "Zurück zu Schritt 1",
|
||||
|
||||
"setup.step3.desc": "Konfigurieren Sie die Einstellungen für Ihren Cloudflare-Tunnel. Der von Ihnen angegebene Tunnelname identifiziert Ihren Tunnel im Cloudflare-Dashboard. Außerdem haben Sie die Möglichkeit, eine primäre Zone und weitere Zonen für das DNS-Scanning anzugeben.",
|
||||
"setup.step3.tunnel_name_help": "Ein beschreibender Name für Ihren Cloudflare-Tunnel. Dieser Name erscheint in Ihrem Cloudflare-Dashboard.",
|
||||
"setup.step3.tunnel_name_note": "DockFlare normalisiert diesen Wert automatisch beim Generieren des lokalen cloudflared-Containernamens.",
|
||||
|
|
@ -440,10 +428,8 @@
|
|||
"setup.step3.scan_zones_placeholder": "z.B. example.com, meine-andere-domain.net",
|
||||
"setup.step3.grace_period_help": "Die Wartezeit (in Sekunden), bevor DNS-Einträge für einen gestoppten Container automatisch entfernt werden. Dies verhindert, dass Einträge sofort gelöscht werden, wenn ein Container nur neu gestartet wird. Minimum: 60 Sekunden.",
|
||||
"setup.step3.back": "Zurück zu Schritt 2",
|
||||
|
||||
"setup.step4.desc": "Überprüfen Sie Ihre Konfigurationsdetails unten, bevor Sie die Einrichtung abschließen. Sobald Sie die Einrichtung abschließen, beginnt DockFlare mit der Verwaltung Ihrer Tunnel auf Basis dieser Einstellungen, und Sie werden zur Anmeldeseite weitergeleitet.",
|
||||
"setup.step4.back": "Zurück",
|
||||
|
||||
"setup.restore.title": "DockFlare-Sicherung wiederherstellen",
|
||||
"setup.restore.desc": "Laden Sie ein DockFlare-Sicherungsarchiv (`.zip`) hoch, um Konfiguration, Zustand und Agent-Schlüssel in diese frische Installation wiederherzustellen.",
|
||||
"setup.restore.file_label": "Sicherungsarchiv (.zip)",
|
||||
|
|
@ -451,14 +437,12 @@
|
|||
"setup.restore.info": "Nach einer erfolgreichen Wiederherstellung werden Sie zur Anmeldeseite weitergeleitet. Vorhandene Agents benötigen möglicherweise einen Moment zum Wiederverbinden.",
|
||||
"setup.restore.submit": "Sicherung wiederherstellen",
|
||||
"setup.restore.manual_link": "DockFlare lieber manuell konfigurieren?",
|
||||
|
||||
"setup.import.title": "Migrationsassistent",
|
||||
"setup.import.desc": "DockFlare hat Einstellungen einer früheren Version (.env-Datei) erkannt. Diese wurden für Sie importiert.",
|
||||
"setup.import.imported_settings": "Importierte Einstellungen",
|
||||
"setup.import.review_text": "Bitte überprüfen Sie die importierten Einstellungen. Wenn sie korrekt sind, fahren Sie mit dem letzten Schritt fort: dem Erstellen Ihres Admin-Benutzerkontos.",
|
||||
"setup.import.proceed": "Mit Migration fortfahren",
|
||||
"setup.import.cancel": "Neue Konfiguration erstellen",
|
||||
|
||||
"modal.access_group.title_create": "Neue Zugriffsgruppe erstellen",
|
||||
"modal.access_group.title_edit": "Zugriffsgruppe bearbeiten",
|
||||
"modal.access_group.tab_authenticated": "Authentifizierter Zugriff",
|
||||
|
|
@ -517,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Automatische Weiterleitung zur Identität",
|
||||
"modal.access_group.app_launcher_visible": "Im App-Launcher sichtbar",
|
||||
"modal.access_group.save_group": "Gruppe speichern",
|
||||
|
||||
"modal.idp.title_create": "Identitätsanbieter hinzufügen",
|
||||
"modal.idp.title_edit": "Identitätsanbieter bearbeiten",
|
||||
"modal.idp.help_text": "Benötigen Sie Hilfe? Siehe",
|
||||
|
|
@ -550,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "Redirect-URI für OAuth-Konfiguration:",
|
||||
"modal.idp.create_provider": "Anbieter erstellen",
|
||||
"modal.idp.save_provider": "Anbieter speichern",
|
||||
|
||||
"js.alert.edit_dialog_error": "Das Bearbeitungsdialogfenster konnte aufgrund eines Fehlers nicht geöffnet werden. Bitte prüfen Sie die Konsole.",
|
||||
"js.alert.sync_error": "Fehler: {error}",
|
||||
"js.alert.sync_error_title": "Synchronisierungsfehler",
|
||||
|
|
@ -660,7 +642,6 @@
|
|||
"js.form.from_agent": "Vom Agent",
|
||||
"js.prompt.delete_tunnel_confirm": "Geben Sie \"delete\" ein, um das Löschen des Tunnels zu bestätigen:",
|
||||
"js.prompt.rename_agent": "Neuen Anzeigenamen für diesen Agent eingeben:",
|
||||
|
||||
"flash.general_settings_updated": "Allgemeine Einstellungen erfolgreich aktualisiert.",
|
||||
"flash.tunnel_name_changed": "Tunnelname geändert. Agent wird zum Übernehmen der Änderungen neu gestartet...",
|
||||
"flash.error_saving_settings": "Beim Speichern der Einstellungen ist ein Fehler aufgetreten.",
|
||||
|
|
@ -716,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Einstellungen bestätigt. Bitte erstellen Sie einen Administratorbenutzer, um fortzufahren.",
|
||||
"flash.setup.required_fields_missing": "Warnung: Erforderliche Felder fehlen (CF_API_TOKEN oder CF_ACCOUNT_ID). Sie können nicht fortfahren.",
|
||||
"flash.setup.setup_complete": "Einrichtung abgeschlossen! Bitte melden Sie sich an, um fortzufahren.",
|
||||
|
||||
"form.setup.username": "Benutzername",
|
||||
"form.setup.password": "Passwort",
|
||||
"form.setup.confirm_password": "Passwort bestätigen",
|
||||
|
|
@ -759,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "Die Konto-ID muss 32 Zeichen lang sein.",
|
||||
"form.cloudflare.api_token": "Cloudflare-API-Token",
|
||||
"form.cloudflare.api_token_length": "Der API-Token muss 40 Zeichen lang sein.",
|
||||
"form.cloudflare.submit": "Cloudflare-Zugangsdaten aktualisieren"
|
||||
"form.cloudflare.submit": "Cloudflare-Zugangsdaten aktualisieren",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agents",
|
||||
"nav.settings": "Settings",
|
||||
"nav.help": "Help",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Cancel",
|
||||
"common.close": "close",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Submit",
|
||||
"common.none": "None",
|
||||
"common.not_set": "Not set",
|
||||
|
||||
"login.title": "Sign In - DockFlare",
|
||||
"login.username_placeholder": "Username",
|
||||
"login.password_placeholder": "Password",
|
||||
"login.submit": "Login",
|
||||
"login.sign_in_with": "Sign in with {provider}",
|
||||
|
||||
"help.title": "Help - {title}",
|
||||
|
||||
"restore.title": "DockFlare is Restarting",
|
||||
"restore.hold_tight": "Hold tight, DockFlare is rebooting...",
|
||||
"restore.flavor_text": "We're loading your restored configuration and giving the tunnel hamsters a quick pep talk.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "Encrypted secrets were imported successfully.",
|
||||
"restore.agents_warming_up": "Agents and rules are warming up.",
|
||||
"restore.refresh_in": "We'll refresh this page automatically in <span id=\"countdown\">{seconds}</span> seconds.",
|
||||
|
||||
"status.title": "Dashboard",
|
||||
"status.initialization_in_progress": "Initialization in Progress...",
|
||||
"status.init_logs_below": "You can view logs below. The UI will update when ready.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Enter a hostname to auto-detect the Cloudflare zone. Select a zone if multiple matches are found.",
|
||||
"status.filter_sort_options": "Filter & Sort Options",
|
||||
"status.showing_rules": "Showing {visible} of {total} rules",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.general_settings": "General Settings",
|
||||
"settings.all_cloudflare_tunnels": "All Cloudflare Tunnels",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "e.g. my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "DockFlare Public URL",
|
||||
"settings.dockflare_public_url_help": "Used for agent deploy script generation and Cloudflare Zero Trust app scoping. The DOCKFLARE_PUBLIC_URL environment variable takes precedence if set.",
|
||||
|
||||
"policies.title": "Access Policies",
|
||||
"policies.advanced_access_policies": "Advanced Access Policies",
|
||||
"policies.create_reusable_desc": "Create reusable access policies to apply with a single label.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Remove this item",
|
||||
"policies.search_select_countries": "Search and select countries to block...",
|
||||
"policies.select_identity_providers": "Select identity providers...",
|
||||
|
||||
"agents.title": "Agent Management",
|
||||
"agents.agents_management": "Agents Management",
|
||||
"agents.force_reconciliation": "Force Reconciliation",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Compose Snippet",
|
||||
"agents.deploy_quick_desc": "Copy this script and paste it directly into your SSH session on the target server.",
|
||||
"agents.deploy_compose_desc": "Save as <code>docker-compose.yml</code>, ensure <code>cloudflare-net</code> network exists, then run <code>docker compose up -d</code>.",
|
||||
|
||||
"setup.title": "DockFlare Setup",
|
||||
"setup.step1.create_admin": "Create Admin User",
|
||||
"setup.step1.final_step": "Final Step: Create Admin User",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "Please review the imported settings. If they are correct, proceed to the final step: creating your admin user account.",
|
||||
"setup.import.proceed": "Proceed with Migration",
|
||||
"setup.import.cancel": "Create New Configuration",
|
||||
|
||||
"modal.access_group.title_create": "Create New Access Group",
|
||||
"modal.access_group.title_edit": "Edit Access Group",
|
||||
"modal.access_group.tab_authenticated": "Authenticated Access",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Auto Redirect to Identity",
|
||||
"modal.access_group.app_launcher_visible": "Visible in App Launcher",
|
||||
"modal.access_group.save_group": "Save Group",
|
||||
|
||||
"modal.idp.title_create": "Add Identity Provider",
|
||||
"modal.idp.title_edit": "Edit Identity Provider",
|
||||
"modal.idp.help_text": "Need help? See",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "Redirect URI for OAuth Configuration:",
|
||||
"modal.idp.create_provider": "Create Provider",
|
||||
"modal.idp.save_provider": "Save Provider",
|
||||
|
||||
"js.alert.edit_dialog_error": "Could not open the edit dialog due to an error. Please check the console.",
|
||||
"js.alert.sync_error": "Error: {error}",
|
||||
"js.alert.sync_error_title": "Sync Error",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "From Agent",
|
||||
"js.prompt.delete_tunnel_confirm": "Type \"delete\" to confirm tunnel deletion:",
|
||||
"js.prompt.rename_agent": "Enter new display name for this agent:",
|
||||
|
||||
"flash.general_settings_updated": "General settings updated successfully.",
|
||||
"flash.tunnel_name_changed": "Tunnel name changed. Restarting the agent to apply changes...",
|
||||
"flash.error_saving_settings": "An error occurred while saving settings.",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Settings confirmed. Please create an admin user to continue.",
|
||||
"flash.setup.required_fields_missing": "Warning: Missing required fields (CF_API_TOKEN or CF_ACCOUNT_ID). You will not be able to proceed.",
|
||||
"flash.setup.setup_complete": "Setup complete! Please log in to continue.",
|
||||
|
||||
"form.setup.username": "Username",
|
||||
"form.setup.password": "Password",
|
||||
"form.setup.confirm_password": "Confirm Password",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "Account ID must be 32 characters long.",
|
||||
"form.cloudflare.api_token": "Cloudflare API Token",
|
||||
"form.cloudflare.api_token_length": "API Token must be 40 characters long.",
|
||||
"form.cloudflare.submit": "Update Cloudflare Credentials"
|
||||
"form.cloudflare.submit": "Update Cloudflare Credentials",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agentes",
|
||||
"nav.settings": "Configuración",
|
||||
"nav.help": "Ayuda",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Cancelar",
|
||||
"common.close": "Cerrar",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Enviar",
|
||||
"common.none": "Ninguno",
|
||||
"common.not_set": "Sin configurar",
|
||||
|
||||
"login.title": "Iniciar sesión - DockFlare",
|
||||
"login.username_placeholder": "Nombre de usuario",
|
||||
"login.password_placeholder": "Contraseña",
|
||||
"login.submit": "Iniciar sesión",
|
||||
"login.sign_in_with": "Iniciar sesión con {provider}",
|
||||
|
||||
"help.title": "Ayuda - {title}",
|
||||
|
||||
"restore.title": "DockFlare se está reiniciando",
|
||||
"restore.hold_tight": "Un momento, DockFlare se está reiniciando...",
|
||||
"restore.flavor_text": "Estamos cargando tu configuración restaurada y animando un poco a los hámsters del túnel.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "Los secretos cifrados se importaron correctamente.",
|
||||
"restore.agents_warming_up": "Los agentes y las reglas se están poniendo en marcha.",
|
||||
"restore.refresh_in": "Actualizaremos esta página automáticamente en <span id=\"countdown\">{seconds}</span> segundos.",
|
||||
|
||||
"status.title": "Panel",
|
||||
"status.initialization_in_progress": "Inicialización en curso...",
|
||||
"status.init_logs_below": "Puedes consultar los registros a continuación. La interfaz se actualizará cuando esté lista.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Introduce un nombre de host para detectar automáticamente la zona de Cloudflare. Selecciona una zona si se encuentran varias coincidencias.",
|
||||
"status.filter_sort_options": "Opciones de filtrado y ordenación",
|
||||
"status.showing_rules": "Mostrando {visible} de {total} reglas",
|
||||
|
||||
"settings.title": "Configuración",
|
||||
"settings.general_settings": "Configuración general",
|
||||
"settings.all_cloudflare_tunnels": "Todos los túneles de Cloudflare",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "p. ej., my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "URL pública de DockFlare",
|
||||
"settings.dockflare_public_url_help": "Se usa para generar el script de despliegue del agente y delimitar el alcance de la aplicación de Cloudflare Zero Trust. La variable de entorno DOCKFLARE_PUBLIC_URL tiene prioridad si está definida.",
|
||||
|
||||
"policies.title": "Políticas de acceso",
|
||||
"policies.advanced_access_policies": "Políticas de acceso avanzadas",
|
||||
"policies.create_reusable_desc": "Crea políticas de acceso reutilizables para aplicarlas con una sola etiqueta.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Eliminar este elemento",
|
||||
"policies.search_select_countries": "Buscar y seleccionar países para bloquear...",
|
||||
"policies.select_identity_providers": "Seleccionar proveedores de identidad...",
|
||||
|
||||
"agents.title": "Gestión de agentes",
|
||||
"agents.agents_management": "Gestión de agentes",
|
||||
"agents.force_reconciliation": "Forzar reconciliación",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Fragmento de Compose",
|
||||
"agents.deploy_quick_desc": "Copia este script y pégalo directamente en tu sesión SSH del servidor de destino.",
|
||||
"agents.deploy_compose_desc": "Guárdalo como <code>docker-compose.yml</code>, asegúrate de que la red <code>cloudflare-net</code> existe y luego ejecuta <code>docker compose up -d</code>.",
|
||||
|
||||
"setup.title": "Configuración de DockFlare",
|
||||
"setup.step1.create_admin": "Crear usuario administrador",
|
||||
"setup.step1.final_step": "Paso final: crear usuario administrador",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "Revisa los ajustes importados. Si son correctos, pasa al paso final: crear tu cuenta de usuario administrador.",
|
||||
"setup.import.proceed": "Continuar con la migración",
|
||||
"setup.import.cancel": "Crear nueva configuración",
|
||||
|
||||
"modal.access_group.title_create": "Crear nuevo grupo de acceso",
|
||||
"modal.access_group.title_edit": "Editar grupo de acceso",
|
||||
"modal.access_group.tab_authenticated": "Acceso autenticado",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Redirección automática a la identidad",
|
||||
"modal.access_group.app_launcher_visible": "Visible en el lanzador de aplicaciones",
|
||||
"modal.access_group.save_group": "Guardar grupo",
|
||||
|
||||
"modal.idp.title_create": "Añadir proveedor de identidad",
|
||||
"modal.idp.title_edit": "Editar proveedor de identidad",
|
||||
"modal.idp.help_text": "¿Necesitas ayuda? Consulta",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "URI de redirección para la configuración de OAuth:",
|
||||
"modal.idp.create_provider": "Crear proveedor",
|
||||
"modal.idp.save_provider": "Guardar proveedor",
|
||||
|
||||
"js.alert.edit_dialog_error": "No se pudo abrir el cuadro de edición debido a un error. Revisa la consola.",
|
||||
"js.alert.sync_error": "Error: {error}",
|
||||
"js.alert.sync_error_title": "Error de sincronización",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "Desde el agente",
|
||||
"js.prompt.delete_tunnel_confirm": "Escribe \"delete\" para confirmar la eliminación del túnel:",
|
||||
"js.prompt.rename_agent": "Introduce un nuevo nombre para mostrar para este agente:",
|
||||
|
||||
"flash.general_settings_updated": "La configuración general se actualizó correctamente.",
|
||||
"flash.tunnel_name_changed": "El nombre del túnel ha cambiado. Reiniciando el agente para aplicar los cambios...",
|
||||
"flash.error_saving_settings": "Se produjo un error al guardar la configuración.",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Configuración confirmada. Crea un usuario administrador para continuar.",
|
||||
"flash.setup.required_fields_missing": "Advertencia: faltan campos obligatorios (CF_API_TOKEN o CF_ACCOUNT_ID). No podrás continuar.",
|
||||
"flash.setup.setup_complete": "¡Configuración completada! Inicia sesión para continuar.",
|
||||
|
||||
"form.setup.username": "Nombre de usuario",
|
||||
"form.setup.password": "Contraseña",
|
||||
"form.setup.confirm_password": "Confirmar contraseña",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "El ID de cuenta debe tener 32 caracteres.",
|
||||
"form.cloudflare.api_token": "Token de API de Cloudflare",
|
||||
"form.cloudflare.api_token_length": "El token de API debe tener 40 caracteres.",
|
||||
"form.cloudflare.submit": "Actualizar credenciales de Cloudflare"
|
||||
"form.cloudflare.submit": "Actualizar credenciales de Cloudflare",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agents",
|
||||
"nav.settings": "Paramètres",
|
||||
"nav.help": "Aide",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Annuler",
|
||||
"common.close": "Fermer",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Envoyer",
|
||||
"common.none": "Aucun",
|
||||
"common.not_set": "Non défini",
|
||||
|
||||
"login.title": "Connexion - DockFlare",
|
||||
"login.username_placeholder": "Nom d'utilisateur",
|
||||
"login.password_placeholder": "Mot de passe",
|
||||
"login.submit": "Se connecter",
|
||||
"login.sign_in_with": "Se connecter avec {provider}",
|
||||
|
||||
"help.title": "Aide - {title}",
|
||||
|
||||
"restore.title": "DockFlare redémarre",
|
||||
"restore.hold_tight": "Un instant, DockFlare redémarre...",
|
||||
"restore.flavor_text": "Nous rechargeons votre configuration restaurée et donnons un petit discours motivant aux hamsters du tunnel.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "Les secrets chiffrés ont été importés avec succès.",
|
||||
"restore.agents_warming_up": "Les agents et les règles sont en cours d'initialisation.",
|
||||
"restore.refresh_in": "Cette page sera actualisée automatiquement dans <span id=\"countdown\">{seconds}</span> secondes.",
|
||||
|
||||
"status.title": "Tableau de bord",
|
||||
"status.initialization_in_progress": "Initialisation en cours...",
|
||||
"status.init_logs_below": "Vous pouvez consulter les journaux ci-dessous. L'interface se mettra à jour lorsqu'elle sera prête.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Saisissez un nom d'hôte pour détecter automatiquement la zone Cloudflare. Sélectionnez une zone si plusieurs correspondances sont trouvées.",
|
||||
"status.filter_sort_options": "Options de filtre et de tri",
|
||||
"status.showing_rules": "Affichage de {visible} règles sur {total}",
|
||||
|
||||
"settings.title": "Paramètres",
|
||||
"settings.general_settings": "Paramètres généraux",
|
||||
"settings.all_cloudflare_tunnels": "Tous les tunnels Cloudflare",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "ex. my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "URL publique DockFlare",
|
||||
"settings.dockflare_public_url_help": "Utilisée pour la génération des scripts de déploiement d'agents et la délimitation de l'application Cloudflare Zero Trust. La variable d'environnement DOCKFLARE_PUBLIC_URL est prioritaire si elle est définie.",
|
||||
|
||||
"policies.title": "Politiques d'accès",
|
||||
"policies.advanced_access_policies": "Politiques d'accès avancées",
|
||||
"policies.create_reusable_desc": "Créez des politiques d'accès réutilisables à appliquer avec un seul label.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Supprimer cet élément",
|
||||
"policies.search_select_countries": "Rechercher et sélectionner les pays à bloquer...",
|
||||
"policies.select_identity_providers": "Sélectionner des fournisseurs d'identité...",
|
||||
|
||||
"agents.title": "Gestion des agents",
|
||||
"agents.agents_management": "Gestion des agents",
|
||||
"agents.force_reconciliation": "Forcer la réconciliation",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Extrait Compose",
|
||||
"agents.deploy_quick_desc": "Copiez ce script et collez-le directement dans votre session SSH sur le serveur cible.",
|
||||
"agents.deploy_compose_desc": "Enregistrez sous <code>docker-compose.yml</code>, vérifiez que le réseau <code>cloudflare-net</code> existe, puis exécutez <code>docker compose up -d</code>.",
|
||||
|
||||
"setup.title": "Configuration de DockFlare",
|
||||
"setup.step1.create_admin": "Créer l'utilisateur administrateur",
|
||||
"setup.step1.final_step": "Étape finale : créer l'utilisateur administrateur",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "Veuillez vérifier les paramètres importés. S'ils sont corrects, passez à l'étape finale : la création de votre compte administrateur.",
|
||||
"setup.import.proceed": "Poursuivre la migration",
|
||||
"setup.import.cancel": "Créer une nouvelle configuration",
|
||||
|
||||
"modal.access_group.title_create": "Créer un nouveau groupe d'accès",
|
||||
"modal.access_group.title_edit": "Modifier le groupe d'accès",
|
||||
"modal.access_group.tab_authenticated": "Accès authentifié",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Redirection automatique vers l'identité",
|
||||
"modal.access_group.app_launcher_visible": "Visible dans le lanceur d'applications",
|
||||
"modal.access_group.save_group": "Enregistrer le groupe",
|
||||
|
||||
"modal.idp.title_create": "Ajouter un fournisseur d'identité",
|
||||
"modal.idp.title_edit": "Modifier le fournisseur d'identité",
|
||||
"modal.idp.help_text": "Besoin d'aide ? Consultez",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "URI de redirection pour la configuration OAuth :",
|
||||
"modal.idp.create_provider": "Créer le fournisseur",
|
||||
"modal.idp.save_provider": "Enregistrer le fournisseur",
|
||||
|
||||
"js.alert.edit_dialog_error": "Impossible d'ouvrir la boîte de dialogue de modification en raison d'une erreur. Veuillez vérifier la console.",
|
||||
"js.alert.sync_error": "Erreur : {error}",
|
||||
"js.alert.sync_error_title": "Erreur de synchronisation",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "Depuis l'agent",
|
||||
"js.prompt.delete_tunnel_confirm": "Saisissez \"delete\" pour confirmer la suppression du tunnel :",
|
||||
"js.prompt.rename_agent": "Saisissez le nouveau nom d'affichage pour cet agent :",
|
||||
|
||||
"flash.general_settings_updated": "Paramètres généraux mis à jour avec succès.",
|
||||
"flash.tunnel_name_changed": "Nom du tunnel modifié. Redémarrage de l'agent pour appliquer les changements...",
|
||||
"flash.error_saving_settings": "Une erreur s'est produite lors de l'enregistrement des paramètres.",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Paramètres confirmés. Veuillez créer un utilisateur administrateur pour continuer.",
|
||||
"flash.setup.required_fields_missing": "Avertissement : des champs obligatoires sont manquants (CF_API_TOKEN ou CF_ACCOUNT_ID). Vous ne pourrez pas continuer.",
|
||||
"flash.setup.setup_complete": "Configuration terminée ! Veuillez vous connecter pour continuer.",
|
||||
|
||||
"form.setup.username": "Nom d'utilisateur",
|
||||
"form.setup.password": "Mot de passe",
|
||||
"form.setup.confirm_password": "Confirmer le mot de passe",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "L'ID de compte doit comporter 32 caractères.",
|
||||
"form.cloudflare.api_token": "Jeton d'API Cloudflare",
|
||||
"form.cloudflare.api_token_length": "Le jeton d'API doit comporter 40 caractères.",
|
||||
"form.cloudflare.submit": "Mettre à jour les identifiants Cloudflare"
|
||||
"form.cloudflare.submit": "Mettre à jour les identifiants Cloudflare",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agents",
|
||||
"nav.settings": "Pengaturan",
|
||||
"nav.help": "Bantuan",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Batal",
|
||||
"common.close": "Tutup",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Kirim",
|
||||
"common.none": "Tidak ada",
|
||||
"common.not_set": "Belum diatur",
|
||||
|
||||
"login.title": "Masuk - DockFlare",
|
||||
"login.username_placeholder": "Nama pengguna",
|
||||
"login.password_placeholder": "Kata sandi",
|
||||
"login.submit": "Masuk",
|
||||
"login.sign_in_with": "Masuk dengan {provider}",
|
||||
|
||||
"help.title": "Bantuan - {title}",
|
||||
|
||||
"restore.title": "DockFlare Sedang Memulai Ulang",
|
||||
"restore.hold_tight": "Tunggu sebentar, DockFlare sedang reboot...",
|
||||
"restore.flavor_text": "Kami sedang memuat konfigurasi yang dipulihkan dan memberi semangat singkat pada hamster tunnel.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "Secret terenkripsi berhasil diimpor.",
|
||||
"restore.agents_warming_up": "Agents dan rules sedang dipersiapkan.",
|
||||
"restore.refresh_in": "Halaman ini akan dimuat ulang secara otomatis dalam <span id=\"countdown\">{seconds}</span> detik.",
|
||||
|
||||
"status.title": "Dashboard",
|
||||
"status.initialization_in_progress": "Inisialisasi Sedang Berlangsung...",
|
||||
"status.init_logs_below": "Anda dapat melihat log di bawah. UI akan diperbarui saat sudah siap.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Masukkan hostname untuk mendeteksi zona Cloudflare secara otomatis. Pilih zona jika ditemukan beberapa kecocokan.",
|
||||
"status.filter_sort_options": "Opsi Filter & Sortir",
|
||||
"status.showing_rules": "Menampilkan {visible} dari {total} rules",
|
||||
|
||||
"settings.title": "Pengaturan",
|
||||
"settings.general_settings": "Pengaturan Umum",
|
||||
"settings.all_cloudflare_tunnels": "Semua Cloudflare Tunnels",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "mis. my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "URL Publik DockFlare",
|
||||
"settings.dockflare_public_url_help": "Digunakan untuk pembuatan script deploy agent dan pembatasan cakupan aplikasi Cloudflare Zero Trust. Variabel lingkungan DOCKFLARE_PUBLIC_URL akan lebih diprioritaskan jika sudah diatur.",
|
||||
|
||||
"policies.title": "Access Policies",
|
||||
"policies.advanced_access_policies": "Advanced Access Policies",
|
||||
"policies.create_reusable_desc": "Buat access policy yang dapat digunakan ulang dan diterapkan dengan satu label.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Hapus item ini",
|
||||
"policies.search_select_countries": "Cari dan pilih negara untuk diblokir...",
|
||||
"policies.select_identity_providers": "Pilih identity providers...",
|
||||
|
||||
"agents.title": "Manajemen Agent",
|
||||
"agents.agents_management": "Manajemen Agents",
|
||||
"agents.force_reconciliation": "Paksa Rekonsiliasi",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Snippet Compose",
|
||||
"agents.deploy_quick_desc": "Salin script ini dan tempelkan langsung ke sesi SSH Anda di server target.",
|
||||
"agents.deploy_compose_desc": "Simpan sebagai <code>docker-compose.yml</code>, pastikan jaringan <code>cloudflare-net</code> sudah ada, lalu jalankan <code>docker compose up -d</code>.",
|
||||
|
||||
"setup.title": "Setup DockFlare",
|
||||
"setup.step1.create_admin": "Buat Admin User",
|
||||
"setup.step1.final_step": "Langkah Terakhir: Buat Admin User",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "Silakan tinjau pengaturan yang diimpor. Jika sudah benar, lanjutkan ke langkah terakhir: membuat akun admin Anda.",
|
||||
"setup.import.proceed": "Lanjutkan Migrasi",
|
||||
"setup.import.cancel": "Buat Konfigurasi Baru",
|
||||
|
||||
"modal.access_group.title_create": "Buat Access Group Baru",
|
||||
"modal.access_group.title_edit": "Edit Access Group",
|
||||
"modal.access_group.tab_authenticated": "Authenticated Access",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Redirect Otomatis ke Identity",
|
||||
"modal.access_group.app_launcher_visible": "Tampilkan di App Launcher",
|
||||
"modal.access_group.save_group": "Simpan Group",
|
||||
|
||||
"modal.idp.title_create": "Tambah Identity Provider",
|
||||
"modal.idp.title_edit": "Edit Identity Provider",
|
||||
"modal.idp.help_text": "Butuh bantuan? Lihat",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "Redirect URI untuk Konfigurasi OAuth:",
|
||||
"modal.idp.create_provider": "Buat Provider",
|
||||
"modal.idp.save_provider": "Simpan Provider",
|
||||
|
||||
"js.alert.edit_dialog_error": "Tidak dapat membuka dialog edit karena terjadi error. Silakan cek console.",
|
||||
"js.alert.sync_error": "Error: {error}",
|
||||
"js.alert.sync_error_title": "Sync Error",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "Dari Agent",
|
||||
"js.prompt.delete_tunnel_confirm": "Ketik \"delete\" untuk mengonfirmasi penghapusan tunnel:",
|
||||
"js.prompt.rename_agent": "Masukkan nama tampilan baru untuk agent ini:",
|
||||
|
||||
"flash.general_settings_updated": "Pengaturan umum berhasil diperbarui.",
|
||||
"flash.tunnel_name_changed": "Nama tunnel diubah. Agent sedang direstart untuk menerapkan perubahan...",
|
||||
"flash.error_saving_settings": "Terjadi error saat menyimpan pengaturan.",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Pengaturan dikonfirmasi. Silakan buat admin user untuk melanjutkan.",
|
||||
"flash.setup.required_fields_missing": "Peringatan: Field wajib (CF_API_TOKEN atau CF_ACCOUNT_ID) belum diisi. Anda tidak dapat melanjutkan.",
|
||||
"flash.setup.setup_complete": "Setup selesai! Silakan login untuk melanjutkan.",
|
||||
|
||||
"form.setup.username": "Nama pengguna",
|
||||
"form.setup.password": "Kata sandi",
|
||||
"form.setup.confirm_password": "Konfirmasi Kata Sandi",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "Account ID harus sepanjang 32 karakter.",
|
||||
"form.cloudflare.api_token": "Cloudflare API Token",
|
||||
"form.cloudflare.api_token_length": "API Token harus sepanjang 40 karakter.",
|
||||
"form.cloudflare.submit": "Perbarui Kredensial Cloudflare"
|
||||
"form.cloudflare.submit": "Perbarui Kredensial Cloudflare",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agenti",
|
||||
"nav.settings": "Impostazioni",
|
||||
"nav.help": "Aiuto",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Annulla",
|
||||
"common.close": "Chiudi",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Invia",
|
||||
"common.none": "Nessuno",
|
||||
"common.not_set": "Non impostato",
|
||||
|
||||
"login.title": "Accedi - DockFlare",
|
||||
"login.username_placeholder": "Nome utente",
|
||||
"login.password_placeholder": "Password",
|
||||
"login.submit": "Accedi",
|
||||
"login.sign_in_with": "Accedi con {provider}",
|
||||
|
||||
"help.title": "Aiuto - {title}",
|
||||
|
||||
"restore.title": "DockFlare si sta riavviando",
|
||||
"restore.hold_tight": "Attendi un momento, DockFlare si sta riavviando...",
|
||||
"restore.flavor_text": "Stiamo caricando la configurazione ripristinata e dando una rapida carica ai criceti del tunnel.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "I segreti crittografati sono stati importati correttamente.",
|
||||
"restore.agents_warming_up": "Gli agenti e le regole si stanno preparando.",
|
||||
"restore.refresh_in": "Questa pagina verrà aggiornata automaticamente tra <span id=\"countdown\">{seconds}</span> secondi.",
|
||||
|
||||
"status.title": "Dashboard",
|
||||
"status.initialization_in_progress": "Inizializzazione in corso...",
|
||||
"status.init_logs_below": "Puoi visualizzare i log qui sotto. L'interfaccia si aggiornerà quando sarà pronta.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Inserisci un hostname per rilevare automaticamente la zona Cloudflare. Se vengono trovate più corrispondenze, seleziona la zona corretta.",
|
||||
"status.filter_sort_options": "Opzioni di filtro e ordinamento",
|
||||
"status.showing_rules": "Visualizzazione di {visible} regole su {total}",
|
||||
|
||||
"settings.title": "Impostazioni",
|
||||
"settings.general_settings": "Impostazioni generali",
|
||||
"settings.all_cloudflare_tunnels": "Tutti i Cloudflare Tunnel",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "ad es. my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "URL pubblica DockFlare",
|
||||
"settings.dockflare_public_url_help": "Utilizzata per la generazione dello script di deploy dell'agente e per la definizione dell'ambito dell'applicazione Cloudflare Zero Trust. La variabile d'ambiente DOCKFLARE_PUBLIC_URL ha la precedenza se impostata.",
|
||||
|
||||
"policies.title": "Criteri di accesso",
|
||||
"policies.advanced_access_policies": "Criteri di accesso avanzati",
|
||||
"policies.create_reusable_desc": "Crea criteri di accesso riutilizzabili da applicare con una singola etichetta.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Rimuovi questo elemento",
|
||||
"policies.search_select_countries": "Cerca e seleziona i paesi da bloccare...",
|
||||
"policies.select_identity_providers": "Seleziona provider di identità...",
|
||||
|
||||
"agents.title": "Gestione agenti",
|
||||
"agents.agents_management": "Gestione agenti",
|
||||
"agents.force_reconciliation": "Forza riconciliazione",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Frammento Compose",
|
||||
"agents.deploy_quick_desc": "Copia questo script e incollalo direttamente nella tua sessione SSH sul server di destinazione.",
|
||||
"agents.deploy_compose_desc": "Salva come <code>docker-compose.yml</code>, assicurati che la rete <code>cloudflare-net</code> esista, quindi esegui <code>docker compose up -d</code>.",
|
||||
|
||||
"setup.title": "Configurazione DockFlare",
|
||||
"setup.step1.create_admin": "Crea utente amministratore",
|
||||
"setup.step1.final_step": "Passaggio finale: crea utente amministratore",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "Controlla le impostazioni importate. Se sono corrette, procedi con il passaggio finale: la creazione del tuo account amministratore.",
|
||||
"setup.import.proceed": "Procedi con la migrazione",
|
||||
"setup.import.cancel": "Crea nuova configurazione",
|
||||
|
||||
"modal.access_group.title_create": "Crea nuovo gruppo di accesso",
|
||||
"modal.access_group.title_edit": "Modifica gruppo di accesso",
|
||||
"modal.access_group.tab_authenticated": "Accesso autenticato",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Reindirizzamento automatico all'identità",
|
||||
"modal.access_group.app_launcher_visible": "Visibile nel launcher applicazioni",
|
||||
"modal.access_group.save_group": "Salva gruppo",
|
||||
|
||||
"modal.idp.title_create": "Aggiungi provider di identità",
|
||||
"modal.idp.title_edit": "Modifica provider di identità",
|
||||
"modal.idp.help_text": "Hai bisogno di aiuto? Consulta",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "URI di redirect per la configurazione OAuth:",
|
||||
"modal.idp.create_provider": "Crea provider",
|
||||
"modal.idp.save_provider": "Salva provider",
|
||||
|
||||
"js.alert.edit_dialog_error": "Impossibile aprire la finestra di modifica a causa di un errore. Controlla la console.",
|
||||
"js.alert.sync_error": "Errore: {error}",
|
||||
"js.alert.sync_error_title": "Errore di sincronizzazione",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "Da agente",
|
||||
"js.prompt.delete_tunnel_confirm": "Digita \"delete\" per confermare l'eliminazione del tunnel:",
|
||||
"js.prompt.rename_agent": "Inserisci un nuovo nome visualizzato per questo agente:",
|
||||
|
||||
"flash.general_settings_updated": "Impostazioni generali aggiornate con successo.",
|
||||
"flash.tunnel_name_changed": "Nome tunnel modificato. Riavvio dell'agente per applicare le modifiche...",
|
||||
"flash.error_saving_settings": "Si è verificato un errore durante il salvataggio delle impostazioni.",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Impostazioni confermate. Crea un utente amministratore per continuare.",
|
||||
"flash.setup.required_fields_missing": "Avviso: mancano campi obbligatori (CF_API_TOKEN o CF_ACCOUNT_ID). Non potrai procedere.",
|
||||
"flash.setup.setup_complete": "Configurazione completata! Accedi per continuare.",
|
||||
|
||||
"form.setup.username": "Nome utente",
|
||||
"form.setup.password": "Password",
|
||||
"form.setup.confirm_password": "Conferma password",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "L'ID account deve essere lungo 32 caratteri.",
|
||||
"form.cloudflare.api_token": "Token API Cloudflare",
|
||||
"form.cloudflare.api_token_length": "Il token API deve essere lungo 40 caratteri.",
|
||||
"form.cloudflare.submit": "Aggiorna credenziali Cloudflare"
|
||||
"form.cloudflare.submit": "Aggiorna credenziali Cloudflare",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "エージェント",
|
||||
"nav.settings": "設定",
|
||||
"nav.help": "ヘルプ",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "キャンセル",
|
||||
"common.close": "閉じる",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "送信",
|
||||
"common.none": "なし",
|
||||
"common.not_set": "未設定",
|
||||
|
||||
"login.title": "サインイン - DockFlare",
|
||||
"login.username_placeholder": "ユーザー名",
|
||||
"login.password_placeholder": "パスワード",
|
||||
"login.submit": "ログイン",
|
||||
"login.sign_in_with": "{provider} でサインイン",
|
||||
|
||||
"help.title": "ヘルプ - {title}",
|
||||
|
||||
"restore.title": "DockFlare を再起動しています",
|
||||
"restore.hold_tight": "しばらくお待ちください。DockFlare を再起動しています...",
|
||||
"restore.flavor_text": "復元した設定を読み込みながら、トンネル担当のハムスターたちにも少し気合いを入れています。",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "暗号化されたシークレットのインポートに成功しました。",
|
||||
"restore.agents_warming_up": "エージェントとルールを起動準備しています。",
|
||||
"restore.refresh_in": "<span id=\"countdown\">{seconds}</span> 秒後にこのページを自動更新します。",
|
||||
|
||||
"status.title": "ダッシュボード",
|
||||
"status.initialization_in_progress": "初期化中...",
|
||||
"status.init_logs_below": "以下でログを確認できます。準備ができ次第、UI が更新されます。",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "ホスト名を入力して Cloudflare ゾーンを自動検出します。複数候補が見つかった場合はゾーンを選択してください。",
|
||||
"status.filter_sort_options": "フィルターと並び替えオプション",
|
||||
"status.showing_rules": "{total} 件中 {visible} 件のルールを表示",
|
||||
|
||||
"settings.title": "設定",
|
||||
"settings.general_settings": "一般設定",
|
||||
"settings.all_cloudflare_tunnels": "すべての Cloudflare Tunnel",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "例: my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "DockFlare パブリック URL",
|
||||
"settings.dockflare_public_url_help": "エージェントデプロイスクリプトの生成および Cloudflare Zero Trust アプリのスコープ設定に使用されます。DOCKFLARE_PUBLIC_URL 環境変数が設定されている場合はそちらが優先されます。",
|
||||
|
||||
"policies.title": "アクセス ポリシー",
|
||||
"policies.advanced_access_policies": "高度なアクセス ポリシー",
|
||||
"policies.create_reusable_desc": "単一のラベルで適用できる再利用可能なアクセス ポリシーを作成します。",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "この項目を削除",
|
||||
"policies.search_select_countries": "ブロックする国を検索して選択...",
|
||||
"policies.select_identity_providers": "ID プロバイダーを選択...",
|
||||
|
||||
"agents.title": "エージェント管理",
|
||||
"agents.agents_management": "エージェント管理",
|
||||
"agents.force_reconciliation": "強制リコンシリエーション",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Compose スニペット",
|
||||
"agents.deploy_quick_desc": "このスクリプトをコピーして、対象サーバーの SSH セッションに直接貼り付けてください。",
|
||||
"agents.deploy_compose_desc": "<code>docker-compose.yml</code> として保存し、<code>cloudflare-net</code> ネットワークが存在することを確認してから <code>docker compose up -d</code> を実行してください。",
|
||||
|
||||
"setup.title": "DockFlare セットアップ",
|
||||
"setup.step1.create_admin": "管理者ユーザーを作成",
|
||||
"setup.step1.final_step": "最終ステップ: 管理者ユーザーを作成",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "インポートされた設定を確認してください。問題なければ最終ステップである管理者アカウント作成へ進んでください。",
|
||||
"setup.import.proceed": "移行を続行",
|
||||
"setup.import.cancel": "新しい設定を作成",
|
||||
|
||||
"modal.access_group.title_create": "新しいアクセス グループを作成",
|
||||
"modal.access_group.title_edit": "アクセス グループを編集",
|
||||
"modal.access_group.tab_authenticated": "認証付きアクセス",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "ID プロバイダーへ自動リダイレクト",
|
||||
"modal.access_group.app_launcher_visible": "アプリ ランチャーに表示",
|
||||
"modal.access_group.save_group": "グループを保存",
|
||||
|
||||
"modal.idp.title_create": "ID プロバイダーを追加",
|
||||
"modal.idp.title_edit": "ID プロバイダーを編集",
|
||||
"modal.idp.help_text": "ヘルプが必要ですか?",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "OAuth 設定用のリダイレクト URI:",
|
||||
"modal.idp.create_provider": "プロバイダーを作成",
|
||||
"modal.idp.save_provider": "プロバイダーを保存",
|
||||
|
||||
"js.alert.edit_dialog_error": "エラーのため編集ダイアログを開けませんでした。コンソールを確認してください。",
|
||||
"js.alert.sync_error": "エラー: {error}",
|
||||
"js.alert.sync_error_title": "同期エラー",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "エージェントから",
|
||||
"js.prompt.delete_tunnel_confirm": "トンネル削除を確認するには \"delete\" と入力してください:",
|
||||
"js.prompt.rename_agent": "このエージェントの新しい表示名を入力してください:",
|
||||
|
||||
"flash.general_settings_updated": "一般設定を更新しました。",
|
||||
"flash.tunnel_name_changed": "トンネル名が変更されました。変更を適用するためエージェントを再起動しています...",
|
||||
"flash.error_saving_settings": "設定の保存中にエラーが発生しました。",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "設定を確認しました。続行するには管理者ユーザーを作成してください。",
|
||||
"flash.setup.required_fields_missing": "警告: 必須フィールド(CF_API_TOKEN または CF_ACCOUNT_ID)が不足しています。このままでは続行できません。",
|
||||
"flash.setup.setup_complete": "セットアップが完了しました。続行するにはログインしてください。",
|
||||
|
||||
"form.setup.username": "ユーザー名",
|
||||
"form.setup.password": "パスワード",
|
||||
"form.setup.confirm_password": "パスワードを確認",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "アカウント ID は 32 文字である必要があります。",
|
||||
"form.cloudflare.api_token": "Cloudflare API トークン",
|
||||
"form.cloudflare.api_token_length": "API トークンは 40 文字である必要があります。",
|
||||
"form.cloudflare.submit": "Cloudflare 認証情報を更新"
|
||||
"form.cloudflare.submit": "Cloudflare 認証情報を更新",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "Agenci",
|
||||
"nav.settings": "Ustawienia",
|
||||
"nav.help": "Pomoc",
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Anuluj",
|
||||
"common.close": "Zamknij",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "Wyślij",
|
||||
"common.none": "Brak",
|
||||
"common.not_set": "Nie ustawiono",
|
||||
|
||||
"login.title": "Logowanie - DockFlare",
|
||||
"login.username_placeholder": "Nazwa użytkownika",
|
||||
"login.password_placeholder": "Hasło",
|
||||
"login.submit": "Zaloguj się",
|
||||
"login.sign_in_with": "Zaloguj się przez {provider}",
|
||||
|
||||
"help.title": "Pomoc - {title}",
|
||||
|
||||
"restore.title": "DockFlare uruchamia się ponownie",
|
||||
"restore.hold_tight": "Chwila cierpliwości, DockFlare uruchamia się ponownie...",
|
||||
"restore.flavor_text": "Ładujemy przywróconą konfigurację i motywujemy tunelowe chomiki do działania.",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "Zaszyfrowane sekrety zostały pomyślnie zaimportowane.",
|
||||
"restore.agents_warming_up": "Agenci i reguły się uruchamiają.",
|
||||
"restore.refresh_in": "Ta strona odświeży się automatycznie za <span id=\"countdown\">{seconds}</span> sekund.",
|
||||
|
||||
"status.title": "Panel",
|
||||
"status.initialization_in_progress": "Trwa inicjalizacja...",
|
||||
"status.init_logs_below": "Poniżej możesz wyświetlić logi. Interfejs zaktualizuje się, gdy będzie gotowy.",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "Wprowadź nazwę hosta, aby automatycznie wykryć strefę Cloudflare. Wybierz strefę, jeśli znaleziono wiele dopasowań.",
|
||||
"status.filter_sort_options": "Opcje filtrowania i sortowania",
|
||||
"status.showing_rules": "Wyświetlono {visible} z {total} reguł",
|
||||
|
||||
"settings.title": "Ustawienia",
|
||||
"settings.general_settings": "Ustawienia ogólne",
|
||||
"settings.all_cloudflare_tunnels": "Wszystkie tunele Cloudflare",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "np. my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "Publiczny URL DockFlare",
|
||||
"settings.dockflare_public_url_help": "Używany do generowania skryptów wdrożenia agentów oraz określania zakresu aplikacji Cloudflare Zero Trust. Zmienna środowiskowa DOCKFLARE_PUBLIC_URL ma pierwszeństwo, jeśli jest ustawiona.",
|
||||
|
||||
"policies.title": "Zasady dostępu",
|
||||
"policies.advanced_access_policies": "Zaawansowane zasady dostępu",
|
||||
"policies.create_reusable_desc": "Twórz wielokrotnego użytku zasady dostępu, które można zastosować pojedynczą etykietą.",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "Usuń ten element",
|
||||
"policies.search_select_countries": "Szukaj i wybierz kraje do zablokowania...",
|
||||
"policies.select_identity_providers": "Wybierz dostawców tożsamości...",
|
||||
|
||||
"agents.title": "Zarządzanie agentami",
|
||||
"agents.agents_management": "Zarządzanie agentami",
|
||||
"agents.force_reconciliation": "Wymuś rekonsyliację",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Fragment Compose",
|
||||
"agents.deploy_quick_desc": "Skopiuj ten skrypt i wklej go bezpośrednio do sesji SSH na docelowym serwerze.",
|
||||
"agents.deploy_compose_desc": "Zapisz jako <code>docker-compose.yml</code>, upewnij się, że sieć <code>cloudflare-net</code> istnieje, a następnie uruchom <code>docker compose up -d</code>.",
|
||||
|
||||
"setup.title": "Konfiguracja DockFlare",
|
||||
"setup.step1.create_admin": "Utwórz użytkownika administratora",
|
||||
"setup.step1.final_step": "Ostatni krok: utwórz użytkownika administratora",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "Sprawdź zaimportowane ustawienia. Jeśli są poprawne, przejdź do ostatniego kroku: utworzenia konta administratora.",
|
||||
"setup.import.proceed": "Kontynuuj migrację",
|
||||
"setup.import.cancel": "Utwórz nową konfigurację",
|
||||
|
||||
"modal.access_group.title_create": "Utwórz nową grupę dostępu",
|
||||
"modal.access_group.title_edit": "Edytuj grupę dostępu",
|
||||
"modal.access_group.tab_authenticated": "Dostęp uwierzytelniony",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "Automatyczne przekierowanie do tożsamości",
|
||||
"modal.access_group.app_launcher_visible": "Widoczne w launcherze aplikacji",
|
||||
"modal.access_group.save_group": "Zapisz grupę",
|
||||
|
||||
"modal.idp.title_create": "Dodaj dostawcę tożsamości",
|
||||
"modal.idp.title_edit": "Edytuj dostawcę tożsamości",
|
||||
"modal.idp.help_text": "Potrzebujesz pomocy? Zobacz",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "Redirect URI do konfiguracji OAuth:",
|
||||
"modal.idp.create_provider": "Utwórz dostawcę",
|
||||
"modal.idp.save_provider": "Zapisz dostawcę",
|
||||
|
||||
"js.alert.edit_dialog_error": "Nie udało się otworzyć okna edycji z powodu błędu. Sprawdź konsolę.",
|
||||
"js.alert.sync_error": "Błąd: {error}",
|
||||
"js.alert.sync_error_title": "Błąd synchronizacji",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "Od agenta",
|
||||
"js.prompt.delete_tunnel_confirm": "Wpisz \"delete\", aby potwierdzić usunięcie tunelu:",
|
||||
"js.prompt.rename_agent": "Wprowadź nową nazwę wyświetlaną dla tego agenta:",
|
||||
|
||||
"flash.general_settings_updated": "Ustawienia ogólne zostały pomyślnie zaktualizowane.",
|
||||
"flash.tunnel_name_changed": "Nazwa tunelu została zmieniona. Ponowne uruchamianie agenta w celu zastosowania zmian...",
|
||||
"flash.error_saving_settings": "Wystąpił błąd podczas zapisywania ustawień.",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "Ustawienia potwierdzone. Utwórz użytkownika administratora, aby kontynuować.",
|
||||
"flash.setup.required_fields_missing": "Ostrzeżenie: brakuje wymaganych pól (CF_API_TOKEN lub CF_ACCOUNT_ID). Nie będzie można kontynuować.",
|
||||
"flash.setup.setup_complete": "Konfiguracja zakończona! Zaloguj się, aby kontynuować.",
|
||||
|
||||
"form.setup.username": "Nazwa użytkownika",
|
||||
"form.setup.password": "Hasło",
|
||||
"form.setup.confirm_password": "Potwierdź hasło",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "ID konta musi mieć 32 znaki.",
|
||||
"form.cloudflare.api_token": "Token API Cloudflare",
|
||||
"form.cloudflare.api_token_length": "Token API musi mieć 40 znaków.",
|
||||
"form.cloudflare.submit": "Zaktualizuj poświadczenia Cloudflare"
|
||||
"form.cloudflare.submit": "Zaktualizuj poświadczenia Cloudflare",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"nav.agents": "代理",
|
||||
"nav.settings": "设置",
|
||||
"nav.help": "帮助",
|
||||
|
||||
"common.ok": "确定",
|
||||
"common.cancel": "取消",
|
||||
"common.close": "关闭",
|
||||
|
|
@ -28,15 +27,12 @@
|
|||
"common.submit": "提交",
|
||||
"common.none": "无",
|
||||
"common.not_set": "未设置",
|
||||
|
||||
"login.title": "登录 - DockFlare",
|
||||
"login.username_placeholder": "用户名",
|
||||
"login.password_placeholder": "密码",
|
||||
"login.submit": "登录",
|
||||
"login.sign_in_with": "使用 {provider} 登录",
|
||||
|
||||
"help.title": "帮助 - {title}",
|
||||
|
||||
"restore.title": "DockFlare 正在重启",
|
||||
"restore.hold_tight": "请稍候,DockFlare 正在重新启动...",
|
||||
"restore.flavor_text": "我们正在加载你恢复的配置,并顺便给隧道仓鼠们打打气。",
|
||||
|
|
@ -44,7 +40,6 @@
|
|||
"restore.secrets_imported": "加密的机密信息已成功导入。",
|
||||
"restore.agents_warming_up": "代理和规则正在预热中。",
|
||||
"restore.refresh_in": "此页面将在 <span id=\"countdown\">{seconds}</span> 秒后自动刷新。",
|
||||
|
||||
"status.title": "仪表板",
|
||||
"status.initialization_in_progress": "正在初始化...",
|
||||
"status.init_logs_below": "你可以在下方查看日志。界面准备就绪后会自动更新。",
|
||||
|
|
@ -159,7 +154,6 @@
|
|||
"status.enter_hostname_autodetect": "输入主机名以自动检测 Cloudflare 区域。如果匹配到多个区域,请选择正确的区域。",
|
||||
"status.filter_sort_options": "筛选和排序选项",
|
||||
"status.showing_rules": "显示 {total} 条规则中的 {visible} 条",
|
||||
|
||||
"settings.title": "设置",
|
||||
"settings.general_settings": "常规设置",
|
||||
"settings.all_cloudflare_tunnels": "所有 Cloudflare Tunnel",
|
||||
|
|
@ -263,7 +257,6 @@
|
|||
"settings.zone_scan_description": "例如:my-other-domain.com, another.dev",
|
||||
"settings.dockflare_public_url_label": "DockFlare 公共 URL",
|
||||
"settings.dockflare_public_url_help": "用于生成代理部署脚本及限定 Cloudflare Zero Trust 应用范围。若已设置 DOCKFLARE_PUBLIC_URL 环境变量,则该变量优先生效。",
|
||||
|
||||
"policies.title": "访问策略",
|
||||
"policies.advanced_access_policies": "高级访问策略",
|
||||
"policies.create_reusable_desc": "创建可复用的访问策略,并通过单个标签进行应用。",
|
||||
|
|
@ -357,7 +350,6 @@
|
|||
"policies.remove_this_item": "移除此项",
|
||||
"policies.search_select_countries": "搜索并选择要阻止的国家/地区...",
|
||||
"policies.select_identity_providers": "选择身份提供商...",
|
||||
|
||||
"agents.title": "代理管理",
|
||||
"agents.agents_management": "代理管理",
|
||||
"agents.force_reconciliation": "强制协调",
|
||||
|
|
@ -413,7 +405,6 @@
|
|||
"agents.deploy_compose_snippet": "Compose 片段",
|
||||
"agents.deploy_quick_desc": "复制此脚本并直接粘贴到目标服务器的 SSH 会话中。",
|
||||
"agents.deploy_compose_desc": "另存为 <code>docker-compose.yml</code>,确保 <code>cloudflare-net</code> 网络已存在,然后运行 <code>docker compose up -d</code>。",
|
||||
|
||||
"setup.title": "DockFlare 设置",
|
||||
"setup.step1.create_admin": "创建管理员用户",
|
||||
"setup.step1.final_step": "最后一步:创建管理员用户",
|
||||
|
|
@ -452,7 +443,6 @@
|
|||
"setup.import.review_text": "请检查已导入的设置。如果无误,请继续最后一步:创建管理员用户账户。",
|
||||
"setup.import.proceed": "继续迁移",
|
||||
"setup.import.cancel": "创建新配置",
|
||||
|
||||
"modal.access_group.title_create": "创建新访问组",
|
||||
"modal.access_group.title_edit": "编辑访问组",
|
||||
"modal.access_group.tab_authenticated": "已验证访问",
|
||||
|
|
@ -511,7 +501,6 @@
|
|||
"modal.access_group.auto_redirect": "自动重定向到身份提供商",
|
||||
"modal.access_group.app_launcher_visible": "在应用启动器中可见",
|
||||
"modal.access_group.save_group": "保存分组",
|
||||
|
||||
"modal.idp.title_create": "添加身份提供商",
|
||||
"modal.idp.title_edit": "编辑身份提供商",
|
||||
"modal.idp.help_text": "需要帮助?请参阅",
|
||||
|
|
@ -544,7 +533,6 @@
|
|||
"modal.idp.redirect_uri_heading": "OAuth 配置的重定向 URI:",
|
||||
"modal.idp.create_provider": "创建提供商",
|
||||
"modal.idp.save_provider": "保存提供商",
|
||||
|
||||
"js.alert.edit_dialog_error": "由于发生错误,无法打开编辑对话框。请检查控制台。",
|
||||
"js.alert.sync_error": "错误:{error}",
|
||||
"js.alert.sync_error_title": "同步错误",
|
||||
|
|
@ -654,7 +642,6 @@
|
|||
"js.form.from_agent": "来自代理",
|
||||
"js.prompt.delete_tunnel_confirm": "输入“delete”以确认删除隧道:",
|
||||
"js.prompt.rename_agent": "为此代理输入新的显示名称:",
|
||||
|
||||
"flash.general_settings_updated": "常规设置已成功更新。",
|
||||
"flash.tunnel_name_changed": "隧道名称已更改。正在重启代理以应用更改...",
|
||||
"flash.error_saving_settings": "保存设置时发生错误。",
|
||||
|
|
@ -710,7 +697,6 @@
|
|||
"flash.setup.settings_confirmed": "设置已确认。请创建管理员用户以继续。",
|
||||
"flash.setup.required_fields_missing": "警告:缺少必填字段(CF_API_TOKEN 或 CF_ACCOUNT_ID)。你将无法继续。",
|
||||
"flash.setup.setup_complete": "设置完成!请登录以继续。",
|
||||
|
||||
"form.setup.username": "用户名",
|
||||
"form.setup.password": "密码",
|
||||
"form.setup.confirm_password": "确认密码",
|
||||
|
|
@ -753,5 +739,40 @@
|
|||
"form.cloudflare.account_id_length": "账户 ID 必须为 32 个字符。",
|
||||
"form.cloudflare.api_token": "Cloudflare API 令牌",
|
||||
"form.cloudflare.api_token_length": "API 令牌必须为 40 个字符。",
|
||||
"form.cloudflare.submit": "更新 Cloudflare 凭据"
|
||||
"form.cloudflare.submit": "更新 Cloudflare 凭据",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
"email.permission_r2": "R2 Storage",
|
||||
"email.permission_granted": "Granted",
|
||||
"email.permission_missing": "Missing",
|
||||
"email.recheck_permissions": "Check Permissions",
|
||||
"email.setup_email": "Setup Email for Domain",
|
||||
"email.setup_complete": "Configured",
|
||||
"email.add_mailbox": "Add Mailbox",
|
||||
"email.dns_verify": "Verify DNS",
|
||||
"email.stats_received": "Emails Received",
|
||||
"email.stats_sent": "Emails Sent",
|
||||
"email.stats_storage": "Storage Used",
|
||||
"email.stats_mailboxes": "Active Mailboxes",
|
||||
"email.container_running": "Running",
|
||||
"email.container_stopped": "Mail Manager or Webmail stopped",
|
||||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
"email.display_name": "Display Name",
|
||||
"email.address": "Address",
|
||||
"email.actions": "Actions",
|
||||
"email.status": "Status",
|
||||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +85,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>
|
||||
|
|
@ -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":"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"}
|
||||
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