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:
ChrispyBacon-dev 2026-04-05 22:37:16 +02:00
parent f792e32f8e
commit 2ec0f4ce4f
156 changed files with 17278 additions and 7568 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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

View 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)

View 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}`);
}
}
};

View file

@ -0,0 +1,51 @@
import { EmailMessage } from "cloudflare:email";
export default {
async fetch(request, env, ctx) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${env.AUTH_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const body = await request.json();
const toHeader = Array.isArray(body.to) ? body.to.join(", ") : body.to;
const rawTo = Array.isArray(body.to) ? body.to[0] : body.to;
const addrMatch = typeof rawTo === 'string' ? rawTo.match(/<([^>]+)>/) : null;
const toAddress = addrMatch ? addrMatch[1] : (typeof rawTo === 'string' ? rawTo.trim() : rawTo);
let mimeMessage = `From: ${body.from}\r\nTo: ${toHeader}\r\n`;
if (body.cc) mimeMessage += `Cc: ${Array.isArray(body.cc) ? body.cc.join(", ") : body.cc}\r\n`;
if (body.bcc) mimeMessage += `Bcc: ${Array.isArray(body.bcc) ? body.bcc.join(", ") : body.bcc}\r\n`;
mimeMessage += `Subject: ${body.subject}\r\n`;
mimeMessage += `Date: ${new Date().toUTCString()}\r\n`;
if (body.replyTo) mimeMessage += `Reply-To: ${body.replyTo}\r\n`;
if (body.inReplyTo) mimeMessage += `In-Reply-To: ${body.inReplyTo}\r\n`;
if (body.references) mimeMessage += `References: ${body.references}\r\n`;
if (body.messageId) mimeMessage += `Message-ID: ${body.messageId}\r\n`;
mimeMessage += `MIME-Version: 1.0\r\n`;
const boundary = "b" + crypto.randomUUID().replace(/-/g, "");
mimeMessage += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
const textBody = body.text || (body.html ? "" : "(no content)");
if (textBody) {
mimeMessage += `--${boundary}\r\nContent-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${textBody}\r\n`;
}
if (body.html) {
mimeMessage += `--${boundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${body.html}\r\n`;
}
mimeMessage += `--${boundary}--\r\n`;
const message = new EmailMessage(body.from, toAddress, mimeMessage);
try {
await env.SEND_EMAIL.send(message);
return new Response(JSON.stringify({ success: true, message_id: body.messageId }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
} catch (e) {
return new Response(JSON.stringify({ success: false, error: e.message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
}
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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';
}

View file

@ -85,7 +85,8 @@
<li><a href="{{ url_for('web.access_policies_page') }}" class="{{ 'active' if request.endpoint == 'web.access_policies_page' else '' }}">{{ t('nav.access_policies') }}</a></li>
<li><a href="{{ url_for('web.agents_page') }}" class="{{ 'active' if request.endpoint == 'web.agents_page' else '' }}">{{ t('nav.agents') }}</a></li>
<li><a href="{{ url_for('web.settings_page') }}" class="{{ 'active' if request.endpoint == 'web.settings_page' else '' }}">{{ t('nav.settings') }}</a></li>
<li><a href="{{ url_for('help.help_page') }}" class="{{ 'active' if request.endpoint.startswith('help.') else '' }}">{{ t('nav.help') }}</a></li>
<li><a href="{{ url_for('email.email_page') }}" class="{{ 'active' if request.endpoint == 'email.email_page' else '' }}">{{ t('nav.email') }}</a></li>
<li><a href="{{ url_for('help.help_page') }}" class="{{ 'active' if request.endpoint.startswith('help.') else '' }}">{{ t('nav.help') }}</a></li>
</ul>
</div>
<a href="{{ url_for('web.status_page') }}" class="px-2" title="Now you're thinking with tunnels">
@ -99,6 +100,7 @@
<li><a href="{{ url_for('web.access_policies_page') }}" class="{{ 'active' if request.endpoint == 'web.access_policies_page' else '' }}">{{ t('nav.access_policies') }}</a></li>
<li><a href="{{ url_for('web.agents_page') }}" class="{{ 'active' if request.endpoint == 'web.agents_page' else '' }}">{{ t('nav.agents') }}</a></li>
<li><a href="{{ url_for('web.settings_page') }}" class="{{ 'active' if request.endpoint == 'web.settings_page' else '' }}">{{ t('nav.settings') }}</a></li>
<li><a href="{{ url_for('email.email_page') }}" class="{{ 'active' if request.endpoint == 'email.email_page' else '' }}">{{ t('nav.email') }}</a></li>
<li><a href="{{ url_for('help.help_page') }}" class="{{ 'active' if request.endpoint.startswith('help.') else '' }}">{{ t('nav.help') }}</a></li>
</ul>
</div>

View 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 %}

View file

@ -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>

View file

@ -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:

View 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}")

View file

@ -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>')

View file

@ -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
View 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"]

View 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

View file

View 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

View 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'])

View 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

View 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')

View file

View 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()

View 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()

View 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

View 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

View 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)

View 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
View 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)

View 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)

View file

@ -0,0 +1,9 @@
flask
flask-limiter
waitress
boto3
PyJWT[crypto]
cryptography
python-dateutil
bleach
requests

14
webmail/Dockerfile Normal file
View 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
View 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"
}
}

View 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
View 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
View 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

File diff suppressed because it is too large Load diff

38
webmail/package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

7
webmail/src/App.vue Normal file
View 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
View 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

View 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
View file

@ -0,0 +1,5 @@
import apiClient from './client';
export const authApi = {
checkAuth: () => apiClient.get('/auth/me'),
};
//# sourceMappingURL=auth.js.map

View 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
View 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
View 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

View 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
View 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
View 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

View 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
View 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`
}

View 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;
}
}

View 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>

View 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

View file

@ -0,0 +1 @@
{"version":3,"file":"AttachmentBar.vue.js","sourceRoot":"","sources":["AttachmentBar.vue"],"names":[],"mappings":"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"}

View 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>

View 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

File diff suppressed because one or more lines are too long

View 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>

View 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

View file

@ -0,0 +1 @@
{"version":3,"file":"ComposeEditor.vue.js","sourceRoot":"","sources":["ComposeEditor.vue"],"names":[],"mappings":"AAsCW,oFAAoF;AAE/F,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AACxD,OAAO,UAAU,MAAM,qBAAqB,CAAA;AAC5C,OAAO,IAAI,MAAM,wBAAwB,CAAA;AACzC,OAAO,KAAK,MAAM,yBAAyB,CAAA;AAC3C,OAAO,UAAU,MAAM,8BAA8B,CAAA;AACrD,OAAO,WAAW,MAAM,+BAA+B,CAAA;AAEvD,MAAM,KAAK,GAAG,WAAW,CAAC;IACxB,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;CAC1C,CAAC,CAAA;AAEF,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAA;AAE/C,MAAM,MAAM,GAAG,SAAS,CAAC;IACvB,OAAO,EAAE,KAAK,CAAC,UAAU;IACzB,UAAU,EAAE;QACV,UAAU;QACV,IAAI;QACJ,KAAK;QACL,UAAU;QACV,WAAW,CAAC,SAAS,CAAC,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC;KAChE;IACD,QAAQ,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE;QACvB,IAAI,CAAC,mBAAmB,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAA;IAC7C,CAAC;CACF,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,sDAAsD,EAAE;CACnE,CAAC,CAAC;AACH,IAAI,SAAS,CAAC,MAAM,EAAE,CAAC;IACvB,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC;QACpF,GAAG,EAAE,KAAK,EAAE,+CAA+C,EAAE;KAC5D,CAAC,CAAC;IACH,yBAAyB,CAAC,uBAAuB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC;QAC1F,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE;gBAC9B,IAAI,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC;oBAAE,OAAO;gBAChC,SAAS,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,CAAC,UAAU,EAAE,CAAC,GAAG,EAAE,CAAC;YACpD,CAAC,EAAC;QACF,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,2BAA2B,EAAE,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE;KAC3G,CAAC,CAAC;IACH,yBAAyB,CAAC,uBAAuB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC;QAC1F,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE;gBAC9B,IAAI,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC;oBAAE,OAAO;gBAChC,SAAS,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,CAAC,YAAY,EAAE,CAAC,GAAG,EAAE,CAAC;YACtD,CAAC,EAAC;QACF,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,2BAA2B,EAAE,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE;KAC1G,CAAC,CAAC;IACH,yBAAyB,CAAC,uBAAuB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC;QAC1F,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE;gBAC9B,IAAI,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC;oBAAE,OAAO;gBAChC,SAAS,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,CAAC,YAAY,EAAE,CAAC,GAAG,EAAE,CAAC;YACtD,CAAC,EAAC;QACF,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,2BAA2B,EAAE,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE;KAChH,CAAC,CAAC;AACH,CAAC;AACD,MAAM,OAAO,GAAI,EAA2H,CAAC,aAAa,CAAC;AAC3J,wDAAwD,CAAA,CAAC;AACzD,aAAa;AACb,MAAM,OAAO,GAAG,2BAA2B,CAAC,OAAO,EAAE,IAAI,OAAO,CAAC;IACjE,MAAM,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC;IAC1B,GAAG,EAAE,KAAK,EAAE,kFAAkF,EAAE;CAC/F,CAAC,CAAC,CAAC;AACJ,MAAM,OAAO,GAAG,OAAO,CAAC;IACxB,MAAM,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC;IAC1B,GAAG,EAAE,KAAK,EAAE,kFAAkF,EAAE;CAC/F,EAAE,GAAG,iCAAiC,CAAC,OAAO,CAAC,CAAC,CAAC;AAClD,+CAA+C,CAAA,CAAC;AAChD,mDAAmD,CAAA,CAAC;AACpD,iDAAiD,CAAA,CAAC;AAClD,qDAAqD,CAAA,CAAC;AACtD,+CAA+C,CAAA,CAAC;AAChD,0DAA0D,CAAA,CAAC;AAC3D,+CAA+C,CAAA,CAAC;AAChD,oDAAoD,CAAA,CAAC;AACrD,gDAAgD,CAAA,CAAC;AACjD,8CAA8C,CAAA,CAAC;AAC/C,mDAAmD,CAAA,CAAC;AACpD,sDAAsD,CAAA,CAAC;AACvD,iDAAiD,CAAA,CAAC;AAClD,0DAA0D,CAAA,CAAC;AAC3D,8CAA8C,CAAA,CAAC;AAC/C,gDAAgD,CAAA,CAAC;AACjD,qDAAqD,CAAA,CAAC;AACtD,4DAA4D,CAAA,CAAC;AAC7D,6DAA6D,CAAA,CAAC;AAO9D,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;YACpD,MAAM,EAAE,MAAuB;SAC9B,CAAC;IACF,CAAC;IACD,KAAK,EAAE,EAAuC;IAC9C,KAAK,EAAE;QACL,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;KAC1C;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,EAAuC;IAC9C,KAAK,EAAE;QACL,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;KAC1C;CACA,CAAC,CAAC;AACH,CAAC,CAAA,kCAAkC"}

View 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>

View 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

View 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"}

View 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>

View 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

File diff suppressed because one or more lines are too long

View 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>

View 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

View 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"}

View 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>

View 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

File diff suppressed because one or more lines are too long

View 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>

View 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

View file

@ -0,0 +1 @@
{"version":3,"file":"MessageList.vue.js","sourceRoot":"","sources":["MessageList.vue"],"names":[],"mappings":"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"}

View 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>

View 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

View file

@ -0,0 +1 @@
{"version":3,"file":"MessageListItem.vue.js","sourceRoot":"","sources":["MessageListItem.vue"],"names":[],"mappings":"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"}

View 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>

View 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

View file

@ -0,0 +1 @@
{"version":3,"file":"SearchBar.vue.js","sourceRoot":"","sources":["SearchBar.vue"],"names":[],"mappings":"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"}

View 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>

View 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

View 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"}

View 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>

View 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

View 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"}

View 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>

View 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