Merge pull request #341 from ChrispyBacon-dev/dockflare-mail

Dockflare mail - v3.0.1 unstable-RC
This commit is contained in:
Chris 2026-04-15 22:37:23 +02:00 committed by GitHub
commit 1da4807a56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 35100 additions and 7616 deletions

View file

@ -4,9 +4,10 @@ name: Docker Image Build and Push
on:
push:
branches:
- "stable" # For 'latest' and 'stable' tags
- "unstable" # For 'unstable' tag
- "dev" # For 'dev' tag
- "stable" # For 'latest' and 'stable' tags
- "unstable" # For 'unstable' tag
- "dev" # For 'dev' tag
- "dockflare-mail" # For 'dockflare-mail' tag
tags:
- "v*.*.*" # Trigger on semantic version tags like v1.2.3
- "v*.*.*-*" # Trigger on pre-release semver tags like v1.2.3-beta.1
@ -14,7 +15,11 @@ on:
branches:
- "stable"
- "unstable"
- "dev" # For pull requests on 'dev' branch
- "dev"
- "dockflare-mail"
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
@ -55,7 +60,7 @@ jobs:
type=ref,event=branch,pattern=stable,value=latest
type=ref,event=branch,pattern=stable,value=stable
type=ref,event=branch,pattern=unstable,value=unstable
type=ref,event=branch,pattern=dev,value=dev
type=ref,event=branch,pattern=dev,value=dev
type=sha,format=short
type=ref,event=tag
type=ref,event=branch
@ -105,7 +110,7 @@ jobs:
type=ref,event=branch,pattern=stable,value=latest
type=ref,event=branch,pattern=stable,value=stable
type=ref,event=branch,pattern=unstable,value=unstable
type=ref,event=branch,pattern=dev,value=dev # Added for 'dev' branch
type=ref,event=branch,pattern=dev,value=dev
type=sha,format=short
type=ref,event=tag
type=ref,event=branch
@ -119,3 +124,203 @@ jobs:
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build_mail_manager_self_hosted:
runs-on: self-hosted
timeout-minutes: 3
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: alplat/dockflare-mail-manager
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-{{premajor}}-{{prerelease}}
type=ref,event=branch,pattern=stable,value=latest
type=ref,event=branch,pattern=stable,value=stable
type=ref,event=branch,pattern=unstable,value=unstable
type=ref,event=branch,pattern=dev,value=dev
type=sha,format=short
type=ref,event=tag
type=ref,event=branch
- name: Build and Push Docker Image
id: build_and_push
uses: docker/build-push-action@v5
with:
context: ./mail-manager
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build_mail_manager_github_hosted_fallback:
needs: build_mail_manager_self_hosted
if: failure() || cancelled()
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: alplat/dockflare-mail-manager
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-{{premajor}}-{{prerelease}}
type=ref,event=branch,pattern=stable,value=latest
type=ref,event=branch,pattern=stable,value=stable
type=ref,event=branch,pattern=unstable,value=unstable
type=ref,event=branch,pattern=dev,value=dev
type=sha,format=short
type=ref,event=tag
type=ref,event=branch
- name: Build and Push Docker Image
id: build_and_push
uses: docker/build-push-action@v5
with:
context: ./mail-manager
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build_webmail_self_hosted:
runs-on: self-hosted
timeout-minutes: 3
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: alplat/dockflare-webmail
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-{{premajor}}-{{prerelease}}
type=ref,event=branch,pattern=stable,value=latest
type=ref,event=branch,pattern=stable,value=stable
type=ref,event=branch,pattern=unstable,value=unstable
type=ref,event=branch,pattern=dev,value=dev
type=sha,format=short
type=ref,event=tag
type=ref,event=branch
- name: Build and Push Docker Image
id: build_and_push
uses: docker/build-push-action@v5
with:
context: ./webmail
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build_webmail_github_hosted_fallback:
needs: build_webmail_self_hosted
if: failure() || cancelled()
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: alplat/dockflare-webmail
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-{{premajor}}-{{prerelease}}
type=ref,event=branch,pattern=stable,value=latest
type=ref,event=branch,pattern=stable,value=stable
type=ref,event=branch,pattern=unstable,value=unstable
type=ref,event=branch,pattern=dev,value=dev
type=sha,format=short
type=ref,event=tag
type=ref,event=branch
- name: Build and Push Docker Image
id: build_and_push
uses: docker/build-push-action@v5
with:
context: ./webmail
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -3,6 +3,40 @@
All notable changes to this project will be documented in this file.
## [v3.1.0] - 2026-04-12
### Added
- **Sovereign Email Suite:** A fully self-hosted email system that uses Cloudflare as a stateless delivery layer while keeping all email data local.
- **Inbound Processing:** Automated setup of Cloudflare Email Routing, Workers, and R2 buckets for message ingestion.
- **Outbound Processing:** Authenticated outbound relay via Cloudflare Workers and the `send_email` binding.
- **Local Data Sovereignty:** All emails are stored in a local SQLite FTS5 database with attachments saved to a dedicated Docker volume.
- **Mail Manager Service:** A new backend service handling MIME parsing, R2 interaction, mailbox management, and a full REST API.
- **PWA-Ready Webmail:** A modern, installable Vue 3 + Vite + TypeScript webmail client.
- **Progressive Web App:** Supports browser installation on desktop and mobile with a standalone display mode.
- **Desktop/Mobile Notifications:** Background push notifications for new mail via Web Push (VAPID) and Service Workers.
- **Offline Support:** App shell caching and a graceful offline fallback page.
- **Modern UI:** 3-panel layout with folder navigation, message list, and rich message display using Shadcn/Vue and Tailwind CSS.
- **Multi-Domain Email Support:** Manage inbound and outbound email for an unlimited number of domains simultaneously.
- **Isolation:** Complete isolation of secrets, R2 buckets, and Cloudflare Worker endpoints per domain.
- **Domain Registry:** Dynamic synchronization of domain configurations from the Master to the Mail Manager.
- **Domain-Aware Webhooks:** Inbound webhook routing correctly identifies domains via custom headers and verifies signatures with domain-specific secrets.
- **Automated Infrastructure Provisioning:**
- **One-Click Setup:** Provision all required Cloudflare resources (Email Routing, Workers, R2, DNS) directly from the DockFlare UI.
- **DNS Repair Tool:** A new "Repair DNS" feature to re-apply or fix missing MX, SPF, DMARC, and DKIM records.
- **Worker Redeployment:** Easily push updates or configuration changes to Cloudflare Workers from the UI.
- **Advanced DNS & DKIM:**
- **Authoritative DKIM Handling:** Automatically fetches and applies DKIM TXT records generated by Cloudflare.
- **Zone-Aware Placement:** Smart placement of email-related DNS records across multiple Cloudflare zones.
- **Security & Performance:**
- **EdDSA JWT Authentication:** Secure communication between Webmail, Mail Manager, and Master using Ed25519 key pairs.
- **Outbound Rate Limiting:** Built-in per-sender rate limiting (50/hour, 200/day) to prevent accidental spamming.
- **FTS5 Search:** Full-text search capabilities for lightning-fast email discovery.
### Changed
- **Navigation:** Added "Email" as a primary navigation item in the Master UI.
- **Login Flow:** Added a "Login to Email" shortcut on the main login page when email services are configured.
- **Docker Compose:** Updated to include `dockflare-mail-manager` and `dockflare-webmail` services with a shared `email` profile.
## [v3.0.9] - 2026-03-21
### Added

View file

@ -14,7 +14,7 @@
<a href="https://github.com/ChrispyBacon-dev/DockFlare/stargazers">
<img src="https://img.shields.io/github/stars/ChrispyBacon-dev/DockFlare?style=for-the-badge" alt="Stars">
</a>
<a href="https://github.com/ChrispyBacon-dev/DockFlare/releases"><img src="https://img.shields.io/badge/Release-v3.0.9-blue.svg?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/ChrispyBacon-dev/DockFlare/releases"><img src="https://img.shields.io/badge/Release-v3.1.0-blue.svg?style=for-the-badge" alt="Release"></a>
<a href="https://hub.docker.com/r/alplat/dockflare"><img src="https://img.shields.io/docker/pulls/alplat/dockflare?style=for-the-badge" alt="Docker Pulls"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Made%20with-Python-1f425f.svg?style=for-the-badge" alt="Python"></a>
<a href="https://github.com/ChrispyBacon-dev/DockFlare/blob/main/LICENSE.MD"><img src="https://img.shields.io/badge/License-GPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
@ -40,18 +40,20 @@ The result is a set-it-and-forget-it workflow with a fully localized native expe
## Core Capabilities
- Automatic service discovery from Docker labels.
- Native multi-language support (10 languages) for the Web UI and Help Center.
- Manual ingress rule management for non-Docker workloads.
- Cloudflare Tunnel ingress orchestration, including advanced origin options.
- Access Group and reusable policy management with application assignment.
- Cloudflare Access application lifecycle management.
- Multi-zone DNS handling and zone-aware record placement.
- Multi-host operation through a master and lightweight agents.
- Cloudflare Zero Trust service token provisioning for secure agent communication.
- One-liner deploy scripts for master and agent deployment.
- Backup and restore of encrypted configuration and runtime state.
- Deep-link shortcuts into Cloudflare Zero Trust pages from the DockFlare UI.
- **Automatic service discovery** from Docker labels.
- **Sovereign Email Suite**: A fully self-hosted email system using Cloudflare Email Routing as a stateless delivery layer with local data sovereignty.
- **Multi-Domain Email Support**: Manage inbound and outbound email for an unlimited number of domains simultaneously.
- **PWA-Ready Webmail**: Modern, installable Vue 3 webmail client with offline support and desktop/mobile push notifications.
- **Automated Infrastructure Provisioning**: One-click setup for Cloudflare Workers, R2 buckets, and Email Routing.
- **Advanced DNS & DKIM Management**: Automatic zone-aware record placement with authoritative DKIM key handling.
- **Native Multi-Language Support** (13 languages) for the Web UI and Help Center.
- **Manual Ingress Rule Management** for non-Docker workloads.
- **Cloudflare Tunnel Ingress Orchestration**, including advanced origin options.
- **Access Group & Reusable Policy Management** with application assignment.
- **Cloudflare Access Application Lifecycle Management**.
- **Multi-Host Operation** through a master and lightweight agents.
- **Secure Agent Communication** via Cloudflare Zero Trust service tokens.
- **Backup & Restore** of encrypted configuration, runtime state, and email data.
## Architecture Overview
@ -60,10 +62,12 @@ Detailed architecture guide: [https://dockflare.app/architecture](https://dockfl
| Component | Purpose |
| --- | --- |
| DockFlare Master | Web UI, encrypted config/state, reconciliation, Cloudflare API orchestration |
| DockFlare Mail Manager | Sovereign email backend, SQLite storage, R2 integration, and webhook handling |
| DockFlare Webmail | PWA-ready mail client with push notification support |
| Redis | Shared cache, coordination, and pub/sub signaling |
| DockFlare Agent | Remote host watcher and command executor for distributed deployments |
| cloudflared | Tunnel connector runtime managed per deployment mode |
| Cloudflare API | Source of truth for Tunnel, DNS, and Access resources |
| Cloudflare API | Source of truth for Tunnel, DNS, Email, and Access resources |
### Reconciliation Flow
@ -102,6 +106,9 @@ For full setup documentation, use the project docs site:
- `Account:Account Settings:Read`
- `Account:Access: Apps and Policies:Edit`
- `Account:Access: Service Tokens:Edit`
- `Account:Email:Edit`
- `Account:Workers Scripts:Edit`
- `Account:R2:Edit`
- `Zone:Zone:Read`
- `Zone:DNS:Edit`
@ -339,3 +346,15 @@ curl http://localhost:5000/api/v2/overview
## Changelog
Release notes are maintained in [CHANGELOG.md](CHANGELOG.md).
ailable in 8 languages directly within the DockFlare UI or online)*
- Product docs: [https://dockflare.app/docs](https://dockflare.app/docs)
- Source docs in repository:
- [Multi-Server Agent Guide](dockflare/app/templates/docs/Multi-Server-Agent.md)
- [Using the Web UI](dockflare/app/templates/docs/Using-the-Web-UI.md)
- [Managing DNS Zones](dockflare/app/templates/docs/Managing-DNS-Zones.md)
- [Identity Providers](dockflare/app/templates/docs/Identity-Providers.md)
## Changelog
Release notes are maintained in [CHANGELOG.md](CHANGELOG.md).

View file

@ -33,16 +33,16 @@ services:
dockflare:
#build: ./dockflare
image: alplat/dockflare:stable
image: alplat/dockflare:unstable
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:unstable
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:unstable
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

@ -25,6 +25,7 @@ from flask import Flask
from flask_wtf.csrf import CSRFProtect
from flask_login import LoginManager
from authlib.integrations.flask_client import OAuth
from flask import request as flask_request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
@ -44,10 +45,16 @@ log_formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', dat
oauth = None
def _get_real_ip():
return (
flask_request.headers.get('CF-Connecting-IP') or
get_remote_address()
)
limiter = Limiter(
key_func=get_remote_address,
key_func=_get_real_ip,
default_limits=[],
storage_uri="memory://"
storage_uri=os.environ.get('REDIS_URL', 'memory://')
)
class QueueLogHandler(logging.Handler):
@ -251,6 +258,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

@ -35,7 +35,7 @@ def _get_int_env(name, default, minimum=None):
return default
# --- DockFlare Version ---
APP_VERSION = "v3.0.9"
APP_VERSION = "v3.1.0"
# --- web: https://dockflare.app ---
# --- github: https://github.com/ChrispyBacon-dev/DockFlare ---
@ -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,353 @@
import logging
import requests
import json
import boto3
from botocore.config import Config as BotoConfig
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):
try:
res = cf_api_request('GET', f'/zones/{zone_id}/email/routing/dns')
required = res.get('result', [])
for record in required:
rtype = record.get('type')
rname = record.get('name')
rcontent = record.get('content')
rpriority = record.get('priority')
if rtype and rname and rcontent:
_safe_create_dns(zone_id, rtype, rname, rcontent, priority=rpriority)
except Exception as e:
logging.warning(f"Could not fetch required email routing DNS records from CF API: {e}, falling back to defaults")
_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 set_worker_cron(script_name, cron_expressions):
"""Set cron triggers for a worker via the Schedules API.
cron_expressions: list of cron strings, e.g. ['*/5 * * * *']
Passing an empty list removes all cron triggers.
"""
schedules = [{"cron": c} for c in cron_expressions]
try:
result = cf_api_request(
'PUT',
f'/accounts/{config.CF_ACCOUNT_ID}/workers/scripts/{script_name}/schedules',
json_data=schedules
)
logging.info(f"Cron triggers set for worker {script_name}: {cron_expressions}")
return result
except Exception as e:
logging.error(f"Failed to set cron triggers for {script_name}: {e}")
raise
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 disable_email_routing(zone_id):
try:
return cf_api_request('POST', f'/zones/{zone_id}/email/routing/disable', json_data={})
except Exception as e:
logging.warning(f"Could not disable email routing for zone {zone_id}: {e}")
def reset_catchall_to_drop(zone_id):
data = {
"matchers": [{"type": "all"}],
"actions": [{"type": "drop"}],
"enabled": True,
"name": "DockFlare: Drop All"
}
try:
return cf_api_request('PUT', f'/zones/{zone_id}/email/routing/rules/catch_all', json_data=data)
except Exception as e:
logging.warning(f"Could not reset catch_all to drop for zone {zone_id}: {e}")
def empty_and_delete_r2_bucket(bucket_name, r2_endpoint, r2_key_id, r2_secret):
try:
client = boto3.client(
's3',
endpoint_url=r2_endpoint,
aws_access_key_id=r2_key_id,
aws_secret_access_key=r2_secret,
config=BotoConfig(signature_version='s3v4'),
)
paginator = client.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=bucket_name):
objects = page.get('Contents', [])
if objects:
client.delete_objects(
Bucket=bucket_name,
Delete={'Objects': [{'Key': o['Key']} for o in objects]}
)
except Exception as e:
logging.warning(f"Could not empty R2 bucket {bucket_name}: {e}")
try:
cf_api_request('DELETE', f'/accounts/{config.CF_ACCOUNT_ID}/r2/buckets/{bucket_name}')
except Exception as e:
logging.warning(f"Could not delete R2 bucket {bucket_name}: {e}")
def scrub_email_dns_records(zone_id, zone_name):
errors = []
for rtype, name in [('MX', zone_name), ('TXT', zone_name), ('TXT', f'_dmarc.{zone_name}')]:
try:
res = cf_api_request('GET', f'/zones/{zone_id}/dns_records?type={rtype}&name={name}')
for record in res.get('result', []):
if rtype == 'TXT' and name == zone_name and 'v=spf1' not in record.get('content', ''):
continue
try:
delete_dns_record_generic(zone_id, record['id'])
except Exception as e:
errors.append(f"DNS {rtype} {name}: {e}")
except Exception as e:
errors.append(f"DNS list {rtype} {name}: {e}")
try:
res = cf_api_request('GET', f'/zones/{zone_id}/dns_records?type=CNAME')
for record in res.get('result', []):
if '_domainkey' in record.get('name', ''):
try:
delete_dns_record_generic(zone_id, record['id'])
except Exception as e:
errors.append(f"DNS CNAME {record['name']}: {e}")
except Exception as e:
errors.append(f"DNS list CNAME: {e}")
return errors
def create_kv_namespace(title):
res = cf_api_request('POST', f'/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces',
json_data={'title': title})
return res.get('result', {}).get('id')
def update_kv_entry(namespace_id, key, value_dict):
url = f"{config.CF_API_BASE_URL}/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces/{namespace_id}/values/{key}"
headers = {"Authorization": f"Bearer {config.CF_API_TOKEN}", "Content-Type": "text/plain"}
response = requests.put(url, data=json.dumps(value_dict), headers=headers, timeout=10)
response.raise_for_status()
return response.json()
def delete_kv_entry(namespace_id, key):
try:
cf_api_request('DELETE',
f'/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces/{namespace_id}/values/{key}')
except Exception as e:
logging.warning(f"Could not delete KV entry {key} from {namespace_id}: {e}")
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,179 @@
async function signPayload(secret, payloadString) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(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));
return signatureArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
async function dispatchWebhook(env, payload) {
const payloadString = JSON.stringify(payload);
const signatureHex = await signPayload(env.WEBHOOK_SECRET, payloadString);
const response = await fetch(env.WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-DockFlare-Signature": signatureHex,
"X-DockFlare-Message-Id": payload.message_id,
"X-DockFlare-Domain": env.DOMAIN_NAME
},
body: payloadString,
signal: AbortSignal.timeout(10000)
});
return response;
}
export default {
// ── Inbound email handler ──────────────────────────────────────────────────-.--...--
async email(message, env, ctx) {
try {
const catchAllEnabled = env.CATCH_ALL_ENABLED === 'true';
if (catchAllEnabled) {
const domain = (env.DOMAIN_NAME || '').toLowerCase();
if (!message.to.toLowerCase().endsWith('@' + domain)) {
message.setReject("Recipient not allowed");
return;
}
} else {
const allowedRecipients = JSON.parse(env.ALLOWED_RECIPIENTS || '[]');
if (!allowedRecipients.includes(message.to)) {
message.setReject("Recipient not allowed");
return;
}
}
// Check quota KV before accepting — reject at SMTP level so sender gets a bounce
if (typeof env.QUOTA_KV !== 'undefined') {
try {
const state = await env.QUOTA_KV.get(message.to, "json");
if (state?.blocked) {
message.setReject("550 5.2.2 Mailbox full");
return;
}
} catch (kvErr) {
// KV unavailable — fall through, webhook safety net handles enforcement
console.warn(`KV quota check failed for ${message.to}: ${kvErr.message}`);
}
}
const messageId = crypto.randomUUID();
const r2Key = `temp_cache/${messageId}.eml`;
const receivedAt = new Date().toISOString();
// Upload to R2 first — email is now safely buffered regardless of what happens next
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 payload = {
message_id: messageId,
from: message.from,
to: message.to,
subject: message.headers.get("subject") || "",
received_at: receivedAt,
r2_key: r2Key,
size_bytes: message.rawSize || 0
};
try {
const webhookResponse = await dispatchWebhook(env, payload);
if (webhookResponse.ok) {
const body = await webhookResponse.json().catch(() => ({}));
if (body.reason === 'over_hard_quota') {
// Mail Manager rejected the email (hard quota exceeded) and already cleaned
// up R2 + the DB entry. Reject at SMTP level so sender gets an NDR bounce.
message.setReject("550 5.2.2 Mailbox full");
return;
}
// On success the mail-manager deletes the R2 file itself after processing.
} else {
// DockFlare returned an error — leave email in R2 for cron retry.
// The email is safely buffered and will be delivered
// automatically when DockFlare is healthy again.
console.warn(`Webhook returned ${webhookResponse.status} for ${messageId} — buffered in R2 for retry`);
}
} catch (webhookErr) {
// DockFlare is unreachable (offline, timeout, network error).
// Email is already in R2. Cron will retry. Do NOT reject.
console.warn(`Webhook unreachable for ${messageId} — buffered in R2 for retry: ${webhookErr.message}`);
}
} catch (err) {
// Only reject if failed to store the email in R2 (truly unrecoverable). - reminder need some tests still
message.setReject(`Worker error: ${err.message}`);
}
},
// ── Cron trigger: retry buffered emails in R2 ─────────────────────────-..-.-.-.-────
async scheduled(event, env, ctx) {
console.log("Cron: scanning R2 temp_cache for buffered emails...");
let cursor;
let processed = 0;
let failed = 0;
do {
const list = await env.EMAIL_BUCKET.list({
prefix: "temp_cache/",
limit: 100,
cursor: cursor
});
for (const object of list.objects) {
const r2Key = object.key;
const meta = object.customMetadata || {};
const messageId = r2Key.replace("temp_cache/", "").replace(".eml", "");
const payload = {
message_id: messageId,
from: meta.from || "",
to: meta.to || "",
subject: meta.subject || "",
received_at: meta.receivedAt || new Date().toISOString(),
r2_key: r2Key,
size_bytes: object.size || 0
};
try {
const response = await dispatchWebhook(env, payload);
if (response.ok) {
const body = await response.json().catch(() => ({}));
if (body.reason === 'over_hard_quota') {
// Mailbox was full when cron retried — webhook already cleaned R2 + set KV block.
// Count as processed (not a retry-able failure).
console.warn(`Cron: buffered email ${messageId} rejected (over_hard_quota) — R2 cleaned by Mail Manager`);
processed++;
} else {
console.log(`Cron: delivered buffered email ${messageId} to DockFlare`);
processed++;
}
} else {
const body = await response.text().catch(() => '');
console.warn(`Cron: webhook returned ${response.status} for ${messageId}: ${body.slice(0, 100)}`);
failed++;
}
} catch (err) {
// DockFlare still offline — will retry on next cron run
console.warn(`Cron: DockFlare still unreachable for ${messageId}: ${err.message}`);
failed++;
}
}
cursor = list.truncated ? list.cursor : undefined;
} while (cursor);
console.log(`Cron: done. processed=${processed} failed=${failed}`);
}
};

View file

@ -0,0 +1,90 @@
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);
const attachments = Array.isArray(body.attachments) ? body.attachments.filter(a => a && a.data_b64) : [];
const hasAttachments = attachments.length > 0;
const innerBoundary = "b" + crypto.randomUUID().replace(/-/g, "");
const outerBoundary = hasAttachments ? "b" + crypto.randomUUID().replace(/-/g, "") : null;
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 textBody = body.text || (body.html ? "" : "(no content)");
if (hasAttachments) {
mimeMessage += `Content-Type: multipart/mixed; boundary="${outerBoundary}"\r\n\r\n`;
mimeMessage += `--${outerBoundary}\r\n`;
mimeMessage += `Content-Type: multipart/alternative; boundary="${innerBoundary}"\r\n\r\n`;
if (textBody) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${textBody}\r\n`;
}
if (body.html) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${body.html}\r\n`;
}
mimeMessage += `--${innerBoundary}--\r\n`;
for (const att of attachments) {
const ct = att.content_type || 'application/octet-stream';
const fn = att.filename || 'attachment';
mimeMessage += `\r\n--${outerBoundary}\r\n`;
mimeMessage += `Content-Type: ${ct}; name="${fn}"\r\n`;
mimeMessage += `Content-Transfer-Encoding: base64\r\n`;
mimeMessage += `Content-Disposition: attachment; filename="${fn}"\r\n\r\n`;
// Chunk base64 at 76 chars per line (RFC 2045)
const b64 = att.data_b64.replace(/(.{76})/g, '$1\r\n');
mimeMessage += `${b64}\r\n`;
}
mimeMessage += `\r\n--${outerBoundary}--\r\n`;
} else {
mimeMessage += `Content-Type: multipart/alternative; boundary="${innerBoundary}"\r\n\r\n`;
if (textBody) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${textBody}\r\n`;
}
if (body.html) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${body.html}\r\n`;
}
mimeMessage += `--${innerBoundary}--\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

@ -333,7 +333,8 @@ def main_application_entrypoint():
'host': config.WAITRESS_HOST,
'port': config.WAITRESS_PORT,
'threads': config.WAITRESS_THREADS,
'expose_tracebacks': False
'expose_tracebacks': False,
'send_bytes': 1,
}
if config.WAITRESS_CONNECTION_LIMIT:

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

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,513 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h1 class="text-3xl font-bold">{{ t('email.title') }}</h1>
<p class="text-sm opacity-60 mt-1">{{ t('email.title_description') }}</p>
</div>
{% if email_enabled %}
<div class="flex gap-2 shrink-0">
<button class="btn btn-outline btn-sm" onclick="emailRedeployWorkers()">Redeploy Workers</button>
<button class="btn btn-primary btn-sm" onclick="emailOpenWebmail()">{{ t('email.webmail_link') }}</button>
</div>
{% endif %}
</div>
<div id="emailPermissionsBanner" class="alert alert-warning shadow-lg mb-8 hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current 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>
<div>
<h3 class="font-bold">{{ t('email.permissions_title') }}</h3>
<div class="text-sm mt-1">
<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>
</div>
<button class="btn btn-sm btn-ghost" onclick="emailCheckPermissions()">{{ t('email.recheck_permissions') }}</button>
</div>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body overflow-visible">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-base-300 pb-4 mb-6 gap-4">
<div>
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.domain_setup') }}</h2>
<p class="text-sm opacity-60 mt-1">{{ t('email.domain_setup_description') }}</p>
</div>
<div class="flex items-center gap-2 w-full sm:w-auto">
<select id="emailZoneSelect" class="select select-bordered select-sm flex-1 sm:w-56">
<option disabled selected>{{ t('email.choose_domain') }}</option>
{% for zone in zones %}
<option value="{{ zone.id }}">{{ zone.name }}</option>
{% endfor %}
</select>
<button id="emailSetupBtn" class="btn btn-primary btn-sm shrink-0" onclick="emailSetupDomain(event)" disabled>{{ t('email.setup_email') }}</button>
</div>
</div>
<table class="table table-zebra w-full">
<colgroup>
<col>
<col class="w-36">
<col class="w-64">
</colgroup>
<thead>
<tr>
<th class="px-4 py-3">{{ t('email.domain') }}</th>
<th class="px-4 py-3">{{ t('email.status') }}</th>
<th class="px-4 py-3 text-right">{{ t('email.actions') }}</th>
</tr>
</thead>
<tbody>
{% if email_config.domains %}
{% for domain, cfg in email_config.domains.items() %}
<tr>
<td class="px-4 py-3">
<div>{{ domain }}</div>
<div class="text-xs opacity-50 mt-1" id="catchAllStatus_{{ domain | replace('.', '_') }}">Catch-All: loading…</div>
</td>
<td class="px-4 py-3"><div class="badge badge-success badge-sm">{{ t('email.setup_complete') }}</div></td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1">
<button class="btn btn-sm btn-ghost" onclick="emailVerifyDns('{{ domain }}', event)">{{ t('email.dns_verify') }}</button>
<button class="btn btn-sm btn-ghost" onclick="emailRepairDns('{{ domain }}', event)">Repair DNS</button>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-sm btn-ghost px-2"></button>
<ul tabindex="0" class="dropdown-content z-50 menu menu-sm p-2 shadow-lg bg-base-100 border border-base-200 rounded-box w-56">
<li><button onclick="emailOpenCatchAllModal('{{ domain }}')">Catch-All Routing</button></li>
<li><button onclick="emailUpdateR2('{{ domain }}', event)">R2 Credentials</button></li>
<li class="menu-title">Danger zone</li>
<li><button onclick="emailTeardownPartial('{{ domain }}', event)" class="text-warning">{{ t('email.teardown_partial') }}</button></li>
<li><button onclick="emailTeardownComplete('{{ domain }}', event)" class="text-error">{{ t('email.teardown_complete') }}</button></li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="3" class="px-4 py-8 text-center opacity-50">{{ t('email.no_domains') }}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</section>
{% if email_enabled %}
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-base-300 pb-4 mb-6 gap-4">
<div>
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.mailbox_management') }}</h2>
<p class="text-sm opacity-60 mt-1">{{ t('email.mailbox_description') }}</p>
</div>
</div>
<div class="mb-6">
<button class="btn btn-primary btn-sm" onclick="document.getElementById('emailAddMailboxModal').showModal()">{{ t('email.add_mailbox') }}</button>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="px-4 py-3">{{ t('email.address') }}</th>
<th class="px-4 py-3 text-center">{{ t('email.stats_received') }}</th>
<th class="px-4 py-3 text-center">{{ t('email.stats_sent') }}</th>
<th class="px-4 py-3">Storage / Quota</th>
<th class="px-4 py-3 text-right">{{ 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 data-mailbox="{{ addr }}">
<td class="px-4 py-3">
<div class="font-mono text-sm">{{ addr }}</div>
<div class="text-xs opacity-50">{{ mb.display_name }} &middot; {{ domain }}</div>
<div class="flex gap-1 mt-1 flex-wrap">
<div class="badge badge-warning badge-sm mb-quota-badge hidden"></div>
<div class="badge badge-info badge-sm mb-ar-badge hidden">Out of Office</div>
</div>
</td>
<td class="px-4 py-3 text-center text-sm mb-received opacity-50"></td>
<td class="px-4 py-3 text-center text-sm mb-sent opacity-50"></td>
<td class="px-4 py-3">
<progress class="progress progress-success w-28 block mb-storage-progress" value="0" max="100"></progress>
<span class="text-xs opacity-50 mb-storage-text"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1 flex-wrap">
<button class="btn btn-sm btn-outline" onclick="emailSetPassword('{{ addr }}', '{{ domain }}')">Set Password</button>
<button class="btn btn-sm btn-outline" onclick="emailEditQuota('{{ addr }}', '{{ domain }}', {{ mb.get('quota_bytes', 10737418240) }})">Quota</button>
<button class="btn btn-sm btn-outline" onclick="emailOpenAutoResponderModal('{{ addr }}', '{{ domain }}')">Auto-Responder</button>
<button class="btn btn-sm btn-error btn-outline" onclick="emailDeleteMailbox('{{ addr }}', '{{ domain }}', event)">{{ t('email.delete') }}</button>
</div>
</td>
</tr>
{% endfor %}
{% endfor %}
{% else %}
<tr><td colspan="5" class="px-4 py-8 text-center opacity-50">{{ t('email.no_mailboxes') }}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<div class="border-b border-base-300 pb-4 mb-6">
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.statistics') }}</h2>
<p class="text-sm opacity-60 mt-1">{{ t('email.statistics_description') }}</p>
</div>
<div class="stats stats-horizontal shadow w-full">
<div class="stat">
<div class="stat-title">{{ t('email.stats_received') }}</div>
<div class="stat-value text-primary" id="statReceived">0</div>
</div>
<div class="stat">
<div class="stat-title">{{ t('email.stats_sent') }}</div>
<div class="stat-value text-secondary" id="statSent">0</div>
</div>
<div class="stat">
<div class="stat-title">{{ t('email.stats_storage') }}</div>
<div class="stat-value text-accent" id="statStorage">0</div>
</div>
<div class="stat">
<div class="stat-title">{{ t('email.stats_mailboxes') }}</div>
<div class="stat-value text-info" id="statMailboxes">0</div>
</div>
</div>
</div>
</section>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<div class="border-b border-base-300 pb-4 mb-6 flex items-start justify-between gap-4">
<div>
<h2 class="card-title text-2xl sm:text-3xl">Delivery Logs</h2>
<p class="text-sm opacity-60 mt-1">Outbound send history and bounce tracking.</p>
</div>
<button class="btn btn-outline btn-sm shrink-0 mt-1" onclick="emailOpenLogsModal()">Investigate</button>
</div>
<div class="stats stats-horizontal shadow w-full flex-wrap">
<div class="stat">
<div class="stat-title">Total Sent</div>
<div class="stat-value text-success" id="dlStatSent">-</div>
</div>
<div class="stat">
<div class="stat-title">Failed</div>
<div class="stat-value text-error" id="dlStatFailed">-</div>
</div>
<div class="stat">
<div class="stat-title">Bounced</div>
<div class="stat-value text-warning" id="dlStatBounced">-</div>
</div>
<div class="stat">
<div class="stat-title">Bounce Rate</div>
<div class="stat-value text-info" id="dlStatRate">-</div>
</div>
</div>
<div id="dlTopReasons" class="mt-4"></div>
</div>
</section>
<dialog id="logsModal" class="modal" onclose="sessionStorage.removeItem('logsModalOpen')">
<div class="modal-box w-screen max-w-none h-screen max-h-none rounded-none m-0 flex flex-col">
<div class="flex items-center justify-between mb-4 shrink-0">
<h3 class="font-bold text-xl">Delivery Log Investigation</h3>
<button class="btn btn-sm btn-ghost" onclick="document.getElementById('logsModal').close()"></button>
</div>
<div role="tablist" class="tabs tabs-bordered mb-4 shrink-0">
<button role="tab" id="logTabOutbound" class="tab tab-active" onclick="emailSwitchLogTab('outbound')">Outbound Log</button>
<button role="tab" id="logTabBounce" class="tab" onclick="emailSwitchLogTab('bounce')">Bounce Log</button>
</div>
<div class="flex flex-wrap gap-3 mb-4 shrink-0 items-end">
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">From date</span></label>
<input type="date" id="logDateFrom" class="input input-bordered input-sm" onchange="emailLoadCurrentLog()" />
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">To date</span></label>
<input type="date" id="logDateTo" class="input input-bordered input-sm" onchange="emailLoadCurrentLog()" />
</div>
<div class="form-control" id="logStatusFilterWrap">
<label class="label py-0"><span class="label-text text-xs">Status</span></label>
<select id="logStatus" class="select select-bordered select-sm" onchange="emailLoadCurrentLog()">
<option value="">All</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="form-control hidden" id="logBounceTypeFilterWrap">
<label class="label py-0"><span class="label-text text-xs">Bounce type</span></label>
<select id="logBounceType" class="select select-bordered select-sm" onchange="emailLoadCurrentLog()">
<option value="">All</option>
<option value="permanent">Permanent</option>
<option value="temporary">Temporary</option>
</select>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div id="logPanelOutbound">
<div class="overflow-x-auto">
<table class="table table-sm">
<thead class="sticky top-0 bg-base-100">
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Subject</th>
<th>Status</th>
<th>Error</th>
</tr>
</thead>
<tbody id="logBodyOutbound"></tbody>
</table>
</div>
<p id="logEmptyOutbound" class="text-center opacity-50 py-6 hidden">No outbound records found.</p>
</div>
<div id="logPanelBounce" class="hidden">
<div class="overflow-x-auto">
<table class="table table-sm">
<thead class="sticky top-0 bg-base-100">
<tr>
<th>Date</th>
<th>Recipient</th>
<th>Type</th>
<th>Reason</th>
</tr>
</thead>
<tbody id="logBodyBounce"></tbody>
</table>
</div>
<p id="logEmptyBounce" class="text-center opacity-50 py-6 hidden">No bounce records found.</p>
</div>
</div>
<div class="flex gap-2 justify-center pt-4 shrink-0 items-center border-t border-base-300 mt-2">
<button class="btn btn-sm btn-ghost" id="logPrevBtn" onclick="emailLogPrevPage()"></button>
<span id="logPageInfo" class="text-sm opacity-60"></span>
<button class="btn btn-sm btn-ghost" id="logNextBtn" onclick="emailLogNextPage()"></button>
</div>
</div>
</dialog>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<div class="border-b border-base-300 pb-4 mb-6">
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.backup_restore') }}</h2>
<p class="text-sm opacity-60 mt-1">{{ t('email.backup_restore_description') }}</p>
</div>
<div class="grid sm:grid-cols-2 gap-8">
<div>
<h3 class="font-semibold mb-1">{{ t('email.backup_title') }}</h3>
<p class="text-sm opacity-60 mb-3">{{ t('email.backup_description') }}</p>
<p class="text-xs text-warning font-medium mb-4">⚠ {{ t('email.backup_security_warning') }}</p>
<a href="{{ url_for('email.email_backup') }}" class="btn btn-primary btn-sm">{{ t('email.download_backup') }}</a>
</div>
<div>
<h3 class="font-semibold mb-1">{{ t('email.restore_title') }}</h3>
<p class="text-sm opacity-60 mb-3">{{ t('email.restore_description') }}</p>
<p class="text-xs text-error font-medium mb-4">⚠ {{ t('email.restore_warning') }}</p>
<div class="flex gap-2 items-center">
<label class="btn btn-outline btn-sm" for="emailRestoreFile">Choose file</label>
<span id="emailRestoreFileName" class="text-sm opacity-50 truncate flex-1">No file chosen</span>
<input type="file" id="emailRestoreFile" accept=".zip" class="hidden" onchange="document.getElementById('emailRestoreFileName').textContent = this.files[0]?.name || 'No file chosen'" />
<button class="btn btn-error btn-sm shrink-0" id="emailRestoreBtn" onclick="emailRestoreBackup()">{{ t('email.restore_backup') }}</button>
</div>
<p id="emailRestoreFeedback" class="text-sm mt-2 font-semibold hidden"></p>
</div>
</div>
<div id="emailOrphanedSection" class="hidden mt-6">
<div class="divider"></div>
<h3 class="font-semibold mb-1">{{ t('email.orphaned_data_title') }}</h3>
<p class="text-sm opacity-60 mb-3">{{ t('email.orphaned_data_description') }}</p>
<div id="emailOrphanedList"></div>
</div>
</div>
</section>
<section class="card bg-base-100 shadow-xl border border-error mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<div class="border-b border-error border-opacity-30 pb-4 mb-6">
<h2 class="card-title text-2xl sm:text-3xl text-error">{{ t('email.nuke_all_title') }}</h2>
<p class="text-sm opacity-60 mt-1">{{ t('email.nuke_all_description') }}</p>
</div>
<div class="flex gap-2">
<button class="btn btn-warning btn-sm" onclick="emailNukeAll(false, event)">{{ t('email.nuke_all_partial') }}</button>
<button class="btn btn-error btn-sm" onclick="emailNukeAll(true, event)">{{ t('email.nuke_all_complete') }}</button>
</div>
<p id="emailNukeFeedback" class="text-sm mt-2 font-semibold hidden"></p>
</div>
</section>
{% else %}
<section class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<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>
</section>
{% endif %}
<dialog id="catchAllModal" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4">Catch-All Routing</h3>
<p class="text-sm opacity-60 mb-4">Route all unmatched addresses on <strong id="catchAllDomainLabel"></strong> to a designated mailbox.</p>
<input type="hidden" id="catchAllDomain" />
<div class="form-control mb-4">
<label class="label"><span class="label-text">Target mailbox</span></label>
<select id="catchAllTarget" class="select select-bordered select-sm w-full"></select>
</div>
<div class="modal-action">
<form method="dialog"><button class="btn btn-ghost btn-sm">Cancel</button></form>
<button class="btn btn-error btn-sm" onclick="emailDisableCatchAll()">Disable</button>
<button class="btn btn-primary btn-sm" onclick="emailSaveCatchAll()">Save</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<dialog id="autoResponderModal" class="modal">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-lg mb-1">Auto-Responder</h3>
<p class="text-sm opacity-60 mb-4" id="autoResponderMailboxLabel"></p>
<input type="hidden" id="arAddress" />
<input type="hidden" id="arDomain" />
<div class="form-control mb-3">
<label class="label py-1"><span class="label-text text-xs">Status</span></label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" id="arIsActive" class="toggle toggle-primary toggle-sm" />
<span class="text-sm" id="arActiveLabel">Enabled</span>
</label>
</div>
<div class="form-control mb-3">
<label class="label py-1"><span class="label-text text-xs">Subject</span></label>
<input type="text" id="arSubject" class="input input-bordered input-sm w-full" placeholder="Auto Reply" />
</div>
<div class="form-control mb-3">
<label class="label py-1"><span class="label-text text-xs">Message body</span></label>
<textarea id="arBody" class="textarea textarea-bordered textarea-sm w-full h-28" placeholder="I am currently out of office…"></textarea>
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Start date (optional)</span></label>
<input type="date" id="arStartDate" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">End date (optional)</span></label>
<input type="date" id="arEndDate" class="input input-bordered input-sm w-full" />
</div>
</div>
<div class="form-control mb-4">
<label class="label py-1"><span class="label-text text-xs">Reply interval (hours) — min time between replies to same sender</span></label>
<input type="number" id="arInterval" class="input input-bordered input-sm w-32" value="24" min="1" max="168" />
</div>
<p id="arFeedback" class="text-sm font-semibold mb-2 hidden"></p>
<div class="modal-action">
<form method="dialog"><button class="btn btn-ghost btn-sm">Cancel</button></form>
<button class="btn btn-error btn-sm" id="arDeleteBtn" onclick="emailDeleteAutoResponder()">Delete</button>
<button class="btn btn-primary btn-sm" onclick="emailSaveAutoResponder()">Save</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<dialog id="emailAddMailboxModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">{{ t('email.add_mailbox') }}</h3>
<div class="space-y-4">
<div class="flex items-center gap-1">
<input type="text" id="newMailboxAddress" placeholder="{{ t('email.address') }}" class="input input-bordered input-sm flex-1" />
<span class="text-sm opacity-40 px-1">@</span>
<select id="newMailboxDomain" class="select select-bordered select-sm">
{% if email_config and email_config.domains %}
{% for domain in email_config.domains %}
<option value="{{ domain }}">{{ domain }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<input type="text" id="newMailboxName" placeholder="{{ t('email.display_name') }}" class="input input-bordered input-sm w-full" />
<div>
<label class="text-sm font-medium mb-2 block">Storage Quota</label>
<input type="range" id="newMailboxQuota" min="0" max="10" step="1" value="6" class="range range-sm w-full" oninput="emailUpdateQuotaLabel('newMailboxQuotaLabel', this.value)" />
<div class="flex justify-between text-xs mt-1">
<span class="opacity-50">100 MB</span>
<span id="newMailboxQuotaLabel" class="font-semibold">10 GB</span>
<span class="opacity-50">Unlimited</span>
</div>
</div>
</div>
<div class="modal-action">
<form method="dialog"><button class="btn btn-ghost">Cancel</button></form>
<button class="btn btn-primary" id="emailAddMailboxSubmitBtn" onclick="emailCreateMailbox(event)">{{ t('email.add_mailbox') }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<dialog id="emailEditQuotaModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-1">Edit Storage Quota</h3>
<p class="text-sm opacity-60 mb-1" id="emailEditQuotaTarget"></p>
<p class="text-sm mb-4" id="emailEditQuotaUsage"></p>
<div>
<input type="range" id="editMailboxQuota" min="0" max="10" step="1" value="6" class="range range-sm w-full" oninput="emailUpdateQuotaLabel('editMailboxQuotaLabel', this.value, 'emailEditQuotaGraceInfo')" />
<div class="flex justify-between text-xs mt-1">
<span class="opacity-50">100 MB</span>
<span id="editMailboxQuotaLabel" class="font-semibold">10 GB</span>
<span class="opacity-50">Unlimited</span>
</div>
<p id="emailEditQuotaGraceInfo" class="text-xs opacity-50 mt-1"></p>
</div>
<div class="modal-action">
<form method="dialog"><button class="btn btn-ghost">Cancel</button></form>
<button class="btn btn-primary" id="emailEditQuotaSubmitBtn">Save</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<dialog id="emailSetPasswordModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-1">Set Password</h3>
<p class="text-sm opacity-60 mb-4" id="emailSetPasswordTarget"></p>
<div class="space-y-3">
<input type="password" id="emailSetPasswordNew" placeholder="New password (min 8 characters)" class="input input-bordered w-full" autocomplete="new-password" />
<input type="password" id="emailSetPasswordConfirm" placeholder="Confirm password" class="input input-bordered w-full" autocomplete="new-password" />
<p id="emailSetPasswordError" class="text-error text-sm hidden"></p>
</div>
<div class="modal-action">
<form method="dialog"><button class="btn btn-ghost">Cancel</button></form>
<button class="btn btn-primary" id="emailSetPasswordSubmitBtn">Set Password</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<div id="emailProgressPanel" class="fixed bottom-4 right-4 w-80 z-50 transition-all duration-300 translate-y-4 opacity-0 pointer-events-none">
<div class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body p-4 gap-2">
<div class="flex items-center justify-between">
<h3 class="font-bold text-sm" id="emailProgressTitle"></h3>
<button class="btn btn-ghost btn-xs btn-circle" id="emailProgressClose"></button>
</div>
<ul id="emailProgressSteps" class="space-y-1 text-sm"></ul>
</div>
</div>
</div>
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', () => { emailCheckPermissions(); emailLoadOrphanedDomains(); emailLoadStats(); emailLoadMailboxStats(); emailLoadDeliveryStats(); emailLoadAllCatchAllStatuses(); emailLoadAutoResponderBadges(); if (sessionStorage.getItem('logsModalOpen')) emailOpenLogsModal(); });
</script>
{% endblock %}
{% endblock %}

View file

@ -128,6 +128,13 @@
</div>
</form>
{% endif %}
{% if email_enabled and webmail_available %}
<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:

File diff suppressed because it is too large Load diff

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', 'email.mailbox_login', 'email.quota_kv_sync']
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:
@ -229,7 +229,7 @@ def add_security_headers_bp(response):
"style-src": ["'self'", "'unsafe-inline'", "https://rsms.me", "https://cdn.jsdelivr.net"],
"img-src": ["'self'", "data:", "https://img.shields.io"],
"font-src": ["'self'", "https://rsms.me"],
"connect-src": ["'self'", "https://cdn.jsdelivr.net"],
"connect-src": ["'self'", "https://cdn.jsdelivr.net", "https://mail.*"],
"frame-src": ["'none'"]
}
if is_https:
@ -455,7 +455,6 @@ def access_policies_page():
policy = access_groups[default_bypass_id]
cf_policy_id = policy.get("cf_policy_id")
# If no Cloudflare policy ID, create it now
if not cf_policy_id or cf_policy_id == default_bypass_id:
try:
cf_policy = reusable_policies.create_reusable_policy(
@ -1068,20 +1067,6 @@ def version_check():
return jsonify(result)
@bp.route('/debug')
def debug_info():
try:
headers = {k: v for k, v in request.headers.items()}
return jsonify({
"request": { "scheme": request.scheme, "is_secure": request.is_secure, "host": request.host,
"path": request.path, "url": request.url, "headers": headers },
"environment": { "wsgi.url_scheme": request.environ.get('wsgi.url_scheme'),
"HTTP_X_FORWARDED_PROTO": request.environ.get('HTTP_X_FORWARDED_PROTO') },
"timestamp": int(time.time())
})
except Exception as e:
logging.error(f"Error in /debug route: {e}", exc_info=True)
return jsonify({ "error": "An internal error occurred.", "status": "error", "timestamp": int(time.time()) }), 500
@bp.route('/reconciliation-status')
def reconciliation_status_route():
@ -2311,12 +2296,20 @@ def login():
p for p in current_app.config.get('OAUTH_PROVIDERS', []) if p.get('enabled', True)
]
from .email_routes import _get_webmail_hostname
webmail_hostname = _get_webmail_hostname()
webmail_available = bool(webmail_hostname)
webmail_url = f"https://{webmail_hostname}" if webmail_available else url_for('email.sso_callback')
return render_template(
'login.html',
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=webmail_url,
webmail_available=webmail_available
)
@bp.route('/login/<provider_id>')

View file

@ -5,6 +5,11 @@
"scripts": {
"build:css": "tailwindcss -c ./tailwind.config.js -i ./app/templates/input.css -o ./app/static/css/output.css --minify"
},
"overrides": {
"picomatch": "4.0.4",
"brace-expansion": "5.0.5",
"yaml": "2.8.3"
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"daisyui": "^4.10.2",

View file

@ -1,5 +1,6 @@
Authlib==1.6.9
cryptography==46.0.5
boto3
cryptography==46.0.7
docker==7.1.0
Flask==3.1.3
Flask-Caching==2.1.0
@ -7,11 +8,12 @@ Flask-Limiter==3.13
Flask-Login==0.6.3
Flask-WTF==1.2.2
Markdown==3.8.1
Pygments==2.19.2
Pygments==2.20.0
PyJWT[crypto]>=2.8.0
pymdown-extensions==10.16.1
python-dotenv==1.1.1
redis==4.5.5
requests==2.32.4
requests==2.33.1
urllib3==2.6.3
waitress==3.0.2
Werkzeug==3.1.6

View file

@ -6,6 +6,16 @@
#
authlib==1.6.9
# via -r dockflare/requirements.in
boto3==1.42.83
# via -r dockflare/requirements.in
botocore==1.42.88
# via boto3
jmespath==1.1.0
# via boto3, botocore
python-dateutil==2.9.0.post0
# via botocore
s3transfer==0.16.0
# via boto3
blinker==1.9.0
# via flask
cachelib==0.9.0
@ -18,7 +28,7 @@ charset-normalizer==3.4.2
# via requests
click==8.2.1
# via flask
cryptography==46.0.5
cryptography==46.0.7
# via
# -r dockflare/requirements.in
# authlib
@ -71,7 +81,7 @@ packaging==26.0
# via limits
pycparser==3.0
# via cffi
pygments==2.19.2
pygments==2.20.0
# via
# -r dockflare/requirements.in
# rich
@ -83,7 +93,7 @@ pyyaml==6.0.3
# via pymdown-extensions
redis==4.5.5
# via -r dockflare/requirements.in
requests==2.32.4
requests==2.33.1
# via
# -r dockflare/requirements.in
# docker
@ -109,3 +119,5 @@ wrapt==2.1.2
# via deprecated
wtforms==3.2.1
# via flask-wtf
pyjwt[crypto]==2.12.1
# via -r dockflare/requirements.in

View file

@ -17,6 +17,18 @@ module.exports = {
'inline-block',
'mr-1',
'text-info',
'bottom-4',
'right-4',
'w-80',
'z-50',
'translate-y-4',
'translate-y-0',
'opacity-0',
'opacity-100',
'pointer-events-none',
'pointer-events-auto',
'space-y-1',
'shrink-0',
],
daisyui: {
themes: [

24
mail-manager/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM python:3.13.12-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,19 @@
from flask import Flask
from .api.routes import api_bp
from .api.webhook import webhook_bp
from .api.system import system_bp
from .core.database import init_db, register_db
from .core.scheduler import start_scheduler
def create_app():
app = Flask(__name__)
init_db()
register_db(app)
start_scheduler()
app.register_blueprint(api_bp, url_prefix='/api/v1')
app.register_blueprint(webhook_bp, url_prefix='/api/v1/webhook')
app.register_blueprint(system_bp, url_prefix='/api/v1/system')
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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
import os
import sqlite3
import zipfile
import json
import threading
import shutil
from datetime import datetime, timezone
from flask import Blueprint, jsonify, send_file, request, after_this_request
from app.config import config
from app.api.middleware import admin_required
system_bp = Blueprint('system', __name__)
@system_bp.route('/backup', methods=['GET'])
@admin_required
def backup_system():
tmp_db_path = '/tmp/backup.db'
tmp_zip_path = f'/tmp/email_backup_{datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")}.zip'
if os.path.exists(tmp_db_path):
os.remove(tmp_db_path)
db_path = config.DB_PATH
try:
conn = sqlite3.connect(db_path)
conn.execute(f"VACUUM INTO '{tmp_db_path}'")
conn.close()
except Exception as e:
return jsonify({"error": str(e)}), 500
manifest = {
"schema": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"files": []
}
try:
with zipfile.ZipFile(tmp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(tmp_db_path, arcname='db/mail.db')
att_path = config.ATTACHMENTS_PATH
if os.path.exists(att_path):
for root, dirs, files in os.walk(att_path):
for f in files:
file_path = os.path.join(root, f)
arc_name = os.path.relpath(file_path, config.MAIL_DATA_PATH)
zf.write(file_path, arcname=arc_name)
zf.writestr('manifest.json', json.dumps(manifest, indent=2))
@after_this_request
def cleanup(response):
if os.path.exists(tmp_db_path):
os.remove(tmp_db_path)
if os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path)
return response
return send_file(tmp_zip_path, as_attachment=True, download_name=os.path.basename(tmp_zip_path))
except Exception as e:
if os.path.exists(tmp_db_path):
os.remove(tmp_db_path)
if os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path)
return jsonify({"error": str(e)}), 500
@system_bp.route('/local-domains', methods=['GET'])
@admin_required
def local_domains():
db_path = config.DB_PATH
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT m.domain,
COUNT(DISTINCT m.address) AS mailbox_count,
COUNT(msg.id) AS message_count
FROM mailboxes m
LEFT JOIN messages msg ON msg.mailbox_address = m.address
GROUP BY m.domain
""").fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
except Exception as e:
return jsonify({"error": str(e)}), 500
@system_bp.route('/wipe-domain', methods=['POST'])
@admin_required
def wipe_domain():
data = request.get_json(force=True, silent=True) or {}
domain = data.get('domain', '').strip()
if not domain:
return jsonify({"error": "domain required"}), 400
config.IN_MAINTENANCE = True
db_path = config.DB_PATH
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
rows = conn.execute(
"SELECT id FROM messages WHERE mailbox_address LIKE ? AND has_attachments=1",
(f'%@{domain}',)
).fetchall()
for row in rows:
shutil.rmtree(os.path.join(config.ATTACHMENTS_PATH, str(row['id'])), ignore_errors=True)
conn.execute("DELETE FROM mailboxes WHERE address LIKE ?", (f'%@{domain}',))
conn.commit()
conn.close()
def _vacuum():
c = sqlite3.connect(db_path)
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
c.execute("VACUUM")
c.close()
threading.Thread(target=_vacuum, daemon=True).start()
config.IN_MAINTENANCE = False
return jsonify({"status": "wiped", "domain": domain})
except Exception as e:
config.IN_MAINTENANCE = False
return jsonify({"error": str(e)}), 500
@system_bp.route('/wipe-all', methods=['POST'])
@admin_required
def wipe_all():
config.IN_MAINTENANCE = True
db_path = config.DB_PATH
try:
att_path = config.ATTACHMENTS_PATH
if os.path.exists(att_path):
shutil.rmtree(att_path)
os.makedirs(att_path, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("DELETE FROM mailboxes")
conn.commit()
conn.close()
def _vacuum():
c = sqlite3.connect(db_path)
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
c.execute("VACUUM")
c.close()
threading.Thread(target=_vacuum, daemon=True).start()
config.IN_MAINTENANCE = False
return jsonify({"status": "wiped"})
except Exception as e:
config.IN_MAINTENANCE = False
return jsonify({"error": str(e)}), 500
def _schedule_restart():
def restart():
import time
time.sleep(2)
os._exit(0)
threading.Thread(target=restart).start()
@system_bp.route('/restore', methods=['POST'])
@admin_required
def restore_system():
config.IN_MAINTENANCE = True
if 'file' not in request.files:
config.IN_MAINTENANCE = False
return jsonify({"error": "No file uploaded"}), 400
file = request.files['file']
tmp_upload_path = '/tmp/restore_upload.zip'
staging_path = '/data/restore_staging'
old_data_path = '/data/old_data'
file.save(tmp_upload_path)
try:
with zipfile.ZipFile(tmp_upload_path, 'r') as zf:
if 'manifest.json' not in zf.namelist():
raise ValueError("Invalid backup archive: missing manifest.json")
if os.path.exists(staging_path):
shutil.rmtree(staging_path)
os.makedirs(staging_path)
zf.extractall(staging_path)
if os.path.exists(old_data_path):
shutil.rmtree(old_data_path)
os.makedirs(old_data_path)
db_dir = os.path.dirname(config.DB_PATH)
att_dir = config.ATTACHMENTS_PATH
if os.path.exists(db_dir):
shutil.move(db_dir, os.path.join(old_data_path, 'db'))
if os.path.exists(att_dir):
shutil.move(att_dir, os.path.join(old_data_path, 'attachments'))
staged_db = os.path.join(staging_path, 'db')
staged_att = os.path.join(staging_path, 'attachments')
if os.path.exists(staged_db):
shutil.move(staged_db, db_dir)
if os.path.exists(staged_att):
shutil.move(staged_att, att_dir)
shutil.rmtree(staging_path)
os.remove(tmp_upload_path)
_schedule_restart()
return jsonify({"status": "success"})
except Exception as e:
config.IN_MAINTENANCE = False
if os.path.exists(tmp_upload_path):
os.remove(tmp_upload_path)
if os.path.exists(staging_path):
shutil.rmtree(staging_path)
if os.path.exists(os.path.join(old_data_path, 'db')):
if os.path.exists(db_dir):
shutil.rmtree(db_dir)
shutil.move(os.path.join(old_data_path, 'db'), db_dir)
if os.path.exists(os.path.join(old_data_path, 'attachments')):
if os.path.exists(att_dir):
shutil.rmtree(att_dir)
shutil.move(os.path.join(old_data_path, 'attachments'), att_dir)
return jsonify({"error": str(e)}), 500

View file

@ -0,0 +1,421 @@
import email as _email_lib
import hmac
import hashlib
import json
import os
import shutil
import sqlite3
import logging
import uuid
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify
import requests as _http_requests
from app.config import config
from app.core.database import get_db
from app.core.push import send_push_notifications
from app.core.r2_client import fetch_email_from_r2, delete_from_r2
from app.core.mime_parser import parse_eml
from app.core.bounce_handler import log_bounce
log = logging.getLogger(__name__)
webhook_bp = Blueprint('webhook', __name__)
def _fmt_bytes(n):
if n >= 1073741824:
return f"{n / 1073741824:.1f} GB"
if n >= 1048576:
return f"{n / 1048576:.1f} MB"
if n >= 1024:
return f"{n / 1024:.1f} KB"
return f"{n} B"
def _detect_and_log_bounce(eml_bytes, parsed):
from_addr = (parsed.get('from_address') or '').lower()
headers = {}
for h in parsed.get('headers_json', []):
for k, v in h.items():
headers[k.lower()] = v
content_type = headers.get('content-type', '').lower()
is_dsn = 'multipart/report' in content_type and 'delivery-status' in content_type
is_mailer_daemon = 'mailer-daemon' in from_addr or (not from_addr and is_dsn)
if not (is_dsn or is_mailer_daemon):
return
msg = _email_lib.message_from_bytes(eml_bytes)
original_message_id = parsed.get('in_reply_to') or ''
bounce_type = 'permanent'
recipient = ''
reason = ''
for part in msg.walk():
if part.get_content_type() == 'message/delivery-status':
try:
payload = part.get_payload(decode=False)
if isinstance(payload, list):
dsn_text = '\n'.join(
p.as_string() if hasattr(p, 'as_string') else str(p)
for p in payload
)
else:
dsn_text = str(payload or '')
for line in dsn_text.splitlines():
if ':' not in line:
continue
key, _, val = line.partition(':')
key = key.strip().lower()
val = val.strip()
if key == 'final-recipient' and not recipient:
parts = val.split(';')
recipient = parts[-1].strip() if len(parts) > 1 else val
elif key == 'status':
bounce_type = 'temporary' if val.startswith('4.') else 'permanent'
elif key == 'diagnostic-code' and not reason:
reason = val.split(';', 1)[-1].strip() if ';' in val else val
elif key == 'original-message-id' and not original_message_id:
original_message_id = val.strip('<>')
except Exception:
pass
if not recipient:
recipient = ', '.join(parsed.get('to_addresses', []))
if not reason:
reason = (parsed.get('subject') or '').strip()
try:
log_bounce(original_message_id, bounce_type, recipient, reason)
except Exception:
log.warning("bounce_log write failed for message_id=%s", original_message_id)
def _check_and_send_auto_reply(db, mailbox_address, parsed, domain_cfg):
from_addr = (parsed.get('from_address') or '').strip()
if not from_addr or 'mailer-daemon' in from_addr.lower():
return
headers = {}
for h in parsed.get('headers_json', []):
for k, v in h.items():
headers[k.lower()] = v
auto_submitted = headers.get('auto-submitted', '').lower()
if auto_submitted and auto_submitted != 'no':
return
precedence = headers.get('precedence', '').lower()
if 'bulk' in precedence or 'list' in precedence or headers.get('list-id'):
return
row = db.execute(
"SELECT * FROM auto_responders WHERE mailbox_address=? AND is_active=1",
(mailbox_address,),
).fetchone()
if not row:
return
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
if row['start_date'] and now_iso < row['start_date']:
return
if row['end_date'] and now_iso > row['end_date'] + 'T23:59:59':
return
cutoff = (now - timedelta(hours=row['reply_interval_hours'])).isoformat()
if db.execute(
"SELECT 1 FROM auto_reply_log WHERE mailbox_address=? AND original_sender=? AND replied_at > ?",
(mailbox_address, from_addr, cutoff),
).fetchone():
return
outbound_url = domain_cfg['outbound_worker_url'] if domain_cfg else None
outbound_auth = domain_cfg['outbound_auth_secret'] if domain_cfg else None
if not outbound_url:
return
msg_id = f"<auto-reply-{uuid.uuid4()}@dockflare>"
original_subject = parsed.get('subject') or ''
worker_payload = {
"from": mailbox_address,
"to": [from_addr],
"subject": f"Auto Reply: {original_subject}",
"text": f"{row['message_body']}\n\n---\nThis is an automated reply.",
"messageId": msg_id,
"inReplyTo": parsed.get('message_id') or '',
"references": parsed.get('message_id') or '',
}
try:
resp = _http_requests.post(
outbound_url,
json=worker_payload,
headers={"Authorization": f"Bearer {outbound_auth}"},
timeout=15,
)
if resp.ok:
db.execute(
"INSERT INTO auto_reply_log (mailbox_address, original_sender, original_message_id, replied_at) VALUES (?, ?, ?, ?)",
(mailbox_address, from_addr, parsed.get('message_id') or '', now_iso),
)
db.commit()
except Exception:
log.warning("Auto-reply send failed for %s -> %s", mailbox_address, from_addr)
def _get_domain_config(domain):
db = get_db()
cur = db.execute("SELECT * FROM domain_configs WHERE domain_name=?", (domain,))
return cur.fetchone()
def _verify_signature(req, secret):
signature = req.headers.get('X-DockFlare-Signature')
if not signature or not secret:
return False
body = req.get_data()
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@webhook_bp.route('/inbound', methods=['POST'])
def inbound():
if getattr(config, 'IN_MAINTENANCE', False):
return jsonify({"error": "Service unavailable during maintenance"}), 503
domain = request.headers.get('X-DockFlare-Domain', '').strip()
if domain and domain != 'undefined':
domain_cfg = _get_domain_config(domain)
if domain_cfg is None:
log.warning("Inbound webhook: unknown domain '%s'", domain)
return jsonify({"error": "unknown domain"}), 401
secret = domain_cfg['webhook_secret']
else:
cur = get_db().execute("SELECT webhook_secret FROM domain_configs LIMIT 1")
row = cur.fetchone()
secret = row['webhook_secret'] if row else config.WEBHOOK_SECRET
domain_cfg = None
if not _verify_signature(request, secret):
return jsonify({"error": "invalid signature"}), 401
data = request.json
if not data:
return jsonify({"error": "missing json body"}), 400
r2_key = data.get('r2_key')
if not r2_key:
return jsonify({"error": "missing r2_key"}), 400
msg_uuid = request.headers.get('X-DockFlare-Message-Id', '')
log.info("Inbound webhook: message=%s domain=%s from=%s to=%s",
msg_uuid, domain or 'legacy', data.get('from', ''), data.get('to', ''))
try:
eml_bytes = fetch_email_from_r2(r2_key, domain_cfg)
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 and domain_cfg and domain_cfg['catch_all_mailbox']:
catch_all = domain_cfg['catch_all_mailbox']
if db.execute("SELECT 1 FROM mailboxes WHERE address=?", (catch_all,)).fetchone():
to_address = catch_all
if not to_address:
log.info("Inbound ignored: no matching mailbox for %s",
parsed['to_addresses'])
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()
actual_size = sum(len(att['data']) for att in parsed['attachments'])
if parsed.get('text_body'):
actual_size += len(parsed['text_body'].encode('utf-8'))
if parsed.get('html_body'):
actual_size += len(parsed['html_body'].encode('utf-8'))
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'], actual_size,
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(config.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()
_detect_and_log_bounce(eml_bytes, parsed)
quota_row = db.execute(
"""SELECT m.quota_bytes, m.last_quota_warning_at, d.grace_buffer_bytes
FROM mailboxes m
LEFT JOIN domain_configs d ON d.domain_name = m.domain
WHERE m.address = ?""",
(to_address,)
).fetchone()
if quota_row and quota_row['quota_bytes'] and quota_row['quota_bytes'] > 0:
quota = quota_row['quota_bytes']
raw_buffer = quota_row['grace_buffer_bytes']
grace = raw_buffer if raw_buffer else max(int(quota * 0.15), 10 * 1024 * 1024)
hard_limit = quota + grace
used = db.execute(
"SELECT COALESCE(SUM(size_bytes), 0) FROM messages WHERE mailbox_address=? AND is_system=0",
(to_address,),
).fetchone()[0]
if used > quota:
db.execute(
"UPDATE mailboxes SET quota_exceeded_count = quota_exceeded_count + 1 WHERE address=?",
(to_address,),
)
log.warning("Quota exceeded for %s: %s used / %s limit", to_address, _fmt_bytes(used), _fmt_bytes(quota))
if not quota_row['last_quota_warning_at']:
inbox = db.execute(
"SELECT id FROM folders WHERE mailbox_address=? AND name='Inbox'",
(to_address,)
).fetchone()
if inbox:
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, is_system
) VALUES (?, ?, ?, 'noreply@dockflare', 'DockFlare System', ?,
'[]', '[]',
'Action Required: Your mailbox is nearly full',
?, '', ?, 0, 0, 0, NULL, NULL, 0, 0, '{}', ?, 1)
""", (
f"quota-warning-{to_address}-{now}",
to_address,
inbox['id'],
f'["{to_address}"]',
(
f"Your mailbox ({to_address}) has reached its storage quota "
f"({_fmt_bytes(quota)}). You have a grace buffer of "
f"{_fmt_bytes(grace)} before new emails are rejected.\n\n"
f"Current usage: {_fmt_bytes(used)}\n"
f"Soft limit: {_fmt_bytes(quota)}\n"
f"Hard limit: {_fmt_bytes(hard_limit)}\n\n"
f"Please delete old messages or contact your administrator "
f"to increase your quota."
),
now, now,
))
db.execute(
"UPDATE mailboxes SET last_quota_warning_at=? WHERE address=?",
(now, to_address)
)
elif used < quota * 0.90 and quota_row['last_quota_warning_at']:
db.execute(
"UPDATE mailboxes SET last_quota_warning_at=NULL WHERE address=?",
(to_address,)
)
db.commit()
if used > hard_limit:
att_dir = os.path.join(config.ATTACHMENTS_PATH, str(msg_id))
if os.path.isdir(att_dir):
shutil.rmtree(att_dir, ignore_errors=True)
db.execute("DELETE FROM messages WHERE id=?", (msg_id,))
db.commit()
log.warning(
"Hard quota exceeded for %s: %s used / %s hard limit — message %d rejected",
to_address, _fmt_bytes(used), _fmt_bytes(hard_limit), msg_id
)
try:
delete_from_r2(r2_key, domain_cfg)
except Exception:
pass
master_url = os.environ.get('DOCKFLARE_MASTER_URL', '').rstrip('/')
if master_url:
try:
_http_requests.post(
f"{master_url}/email/internal/quota-kv-sync",
json={'domain': to_address.split('@')[1], 'address': to_address, 'action': 'block'},
headers={'X-Bootstrap-Token': os.environ.get('INTERNAL_BOOTSTRAP_SECRET', '')},
timeout=3,
)
except Exception:
pass
return jsonify({"status": "rejected", "reason": "over_hard_quota"}), 200
send_push_notifications(to_address, {
'message_id': msg_id,
'subject': parsed['subject'],
'from_name': parsed['from_name'] or parsed['from_address'],
'mailbox': to_address,
})
_check_and_send_auto_reply(db, to_address, parsed, domain_cfg)
delete_from_r2(r2_key, domain_cfg)
log.info("Inbound delivered: message=%s to=%s db_id=%s",
msg_uuid, to_address, msg_id)
return jsonify({"status": "success"})
except sqlite3.IntegrityError:
log.info("Inbound duplicate (already delivered): message=%s — cleaning R2", msg_uuid)
try:
delete_from_r2(r2_key, domain_cfg)
except Exception:
pass
return jsonify({"status": "already_delivered"}), 200
except Exception as e:
log.exception("Inbound webhook failed: message=%s", msg_uuid)
return jsonify({"error": str(e)}), 500

View file

@ -0,0 +1,71 @@
import os
def _env(key, default=''):
return os.environ.get(key, default)
class _Config:
@property
def JWT_PUBLIC_KEY(self):
return _env('JWT_PUBLIC_KEY')
@property
def JWT_ALGORITHM(self):
return _env('JWT_ALGORITHM', 'EdDSA')
@property
def JWT_ISSUER(self):
return _env('JWT_ISSUER', 'dockflare-master')
@property
def JWT_AUDIENCE(self):
return _env('JWT_AUDIENCE', 'dockflare-mail')
@property
def WEBHOOK_SECRET(self):
return _env('WEBHOOK_SECRET')
@property
def R2_ENDPOINT_URL(self):
return _env('R2_ENDPOINT_URL')
@property
def R2_ACCESS_KEY_ID(self):
return _env('R2_ACCESS_KEY_ID')
@property
def R2_SECRET_ACCESS_KEY(self):
return _env('R2_SECRET_ACCESS_KEY')
@property
def R2_BUCKET_NAME(self):
return _env('R2_BUCKET_NAME')
@property
def MAIL_DATA_PATH(self):
return _env('MAIL_DATA_PATH', '/data')
@property
def OUTBOUND_WORKER_URL(self):
return _env('OUTBOUND_WORKER_URL')
@property
def OUTBOUND_AUTH_SECRET(self):
return _env('OUTBOUND_AUTH_SECRET')
@property
def DB_PATH(self):
return os.path.join(self.MAIL_DATA_PATH, 'db', 'mail.db')
@property
def ATTACHMENTS_PATH(self):
return os.path.join(self.MAIL_DATA_PATH, 'attachments')
@property
def APP_VERSION(self):
return '3.1.0'
config = _Config()
config.IN_MAINTENANCE = False

View file

View file

@ -0,0 +1,11 @@
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()

View file

@ -0,0 +1,263 @@
import sqlite3
import os
from flask import g
from app.config import config
_SCHEMA = """
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,
color TEXT,
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 TABLE IF NOT EXISTS domain_configs (
domain_name TEXT PRIMARY KEY,
webhook_secret TEXT NOT NULL,
r2_bucket TEXT NOT NULL,
r2_access_key_id TEXT NOT NULL,
r2_secret_access_key TEXT NOT NULL,
r2_endpoint_url TEXT NOT NULL,
outbound_worker_url TEXT NOT NULL,
outbound_auth_secret TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_domain_configs_name ON domain_configs(domain_name);
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);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_address TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_attempted_at TEXT,
last_success_at TEXT,
fail_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE,
UNIQUE(mailbox_address, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_mailbox ON push_subscriptions(mailbox_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;
"""
def _connect():
conn = sqlite3.connect(config.DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA foreign_keys=ON')
conn.execute('PRAGMA busy_timeout=5000')
return conn
def get_db():
if 'db' not in g:
g.db = _connect()
return g.db
def close_db(exc=None):
db = g.pop('db', None)
if db is not None:
db.close()
def get_standalone_db():
return _connect()
def _migrate(conn):
for sql in [
"ALTER TABLE folders ADD COLUMN color TEXT",
"ALTER TABLE mailboxes ADD COLUMN notification_preview INTEGER DEFAULT 1",
]:
try:
conn.execute(sql)
except Exception:
pass
try:
row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='push_subscriptions'"
).fetchone()
if row and 'UNIQUE(mailbox_address, endpoint)' not in (row[0] or ''):
conn.executescript("""
CREATE TABLE IF NOT EXISTS push_subscriptions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_address TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE,
UNIQUE(mailbox_address, endpoint)
);
INSERT OR IGNORE INTO push_subscriptions_new
SELECT id, mailbox_address, endpoint, p256dh, auth, created_at
FROM push_subscriptions;
DROP TABLE push_subscriptions;
ALTER TABLE push_subscriptions_new RENAME TO push_subscriptions;
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_mailbox ON push_subscriptions(mailbox_address);
""")
except Exception:
pass
for sql in [
"ALTER TABLE push_subscriptions ADD COLUMN last_attempted_at TEXT",
"ALTER TABLE push_subscriptions ADD COLUMN last_success_at TEXT",
"ALTER TABLE push_subscriptions ADD COLUMN fail_count INTEGER NOT NULL DEFAULT 0",
"CREATE INDEX IF NOT EXISTS idx_push_subscriptions_endpoint ON push_subscriptions(endpoint)",
"CREATE INDEX IF NOT EXISTS idx_push_subscriptions_last_success ON push_subscriptions(last_success_at)",
"ALTER TABLE mailboxes ADD COLUMN quota_bytes INTEGER NOT NULL DEFAULT 10737418240",
"ALTER TABLE mailboxes ADD COLUMN quota_exceeded_count INTEGER NOT NULL DEFAULT 0",
"CREATE INDEX IF NOT EXISTS idx_send_log_sent_at ON send_log(sent_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_bounce_log_received_at ON bounce_log(received_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_bounce_log_message_id ON bounce_log(original_message_id)",
"ALTER TABLE domain_configs ADD COLUMN catch_all_mailbox TEXT DEFAULT NULL",
"ALTER TABLE mailboxes ADD COLUMN last_quota_warning_at TEXT DEFAULT NULL",
"ALTER TABLE domain_configs ADD COLUMN grace_buffer_bytes INTEGER DEFAULT NULL",
"ALTER TABLE messages ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0",
]:
try:
conn.execute(sql)
except Exception:
pass
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS auto_responders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_address TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT 'Auto Reply',
message_body TEXT NOT NULL,
start_date TEXT,
end_date TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
reply_interval_hours INTEGER NOT NULL DEFAULT 24,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS auto_reply_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_address TEXT NOT NULL,
original_sender TEXT NOT NULL,
original_message_id TEXT,
replied_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_auto_responders_mailbox ON auto_responders(mailbox_address);
CREATE INDEX IF NOT EXISTS idx_auto_reply_log_lookup ON auto_reply_log(mailbox_address, original_sender, replied_at);
""")
except Exception:
pass
def init_db():
import logging
os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True)
conn = _connect()
conn.executescript(_SCHEMA)
_migrate(conn)
result = conn.execute("PRAGMA quick_check").fetchone()
if result and result[0] != 'ok':
logging.getLogger('mail-manager').critical("SQLite integrity check failed: %s", result[0])
conn.commit()
conn.close()
def register_db(app):
app.teardown_appcontext(close_db)

View file

@ -0,0 +1,17 @@
import jwt
from app.config import config
def verify_jwt(token):
if not config.JWT_PUBLIC_KEY:
return None
try:
return jwt.decode(
token,
config.JWT_PUBLIC_KEY,
algorithms=[config.JWT_ALGORITHM],
issuer=config.JWT_ISSUER,
audience=config.JWT_AUDIENCE,
)
except Exception:
return None

View file

@ -0,0 +1,101 @@
import email
import email.policy
import email.utils
from datetime import datetime, timezone
import nh3
_ALLOWED_TAGS = {
'a', 'abbr', 'acronym', 'b', 'blockquote', 'br', 'code', 'div', 'em',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'i', 'img', 'li', 'ol', 'p',
'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th',
'thead', 'tr', 'u', 'ul',
}
_ALLOWED_ATTRIBUTES = {
'*': {'class', 'style', 'id'},
'a': {'href', 'title', 'target'},
'img': {'src', 'alt', 'width', 'height'},
}
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 from_header:
from_name, from_addr = email.utils.parseaddr(str(from_header))
parsed['from_address'] = from_addr or str(from_header)
parsed['from_name'] = from_name
for addr_header in ['To', 'Cc', 'Bcc']:
val = msg.get(addr_header, '')
if val:
pairs = email.utils.getaddresses([str(val)])
parsed[f'{addr_header.lower()}_addresses'] = [
addr for _, addr in pairs if addr
]
try:
if parsed['date']:
dt = email.utils.parsedate_to_datetime(parsed['date'])
else:
dt = 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'] += nh3.clean(
raw_html,
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRIBUTES,
)
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,136 @@
import json
import logging
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
log = logging.getLogger(__name__)
def send_push_notifications(mailbox_address: str, payload: dict):
threading.Thread(
target=_dispatch,
args=(mailbox_address, payload),
daemon=True,
).start()
def _get_private_key() -> str:
private_key = os.environ.get('VAPID_PRIVATE_KEY', '')
if not private_key:
return ''
if private_key.strip().startswith('-----'):
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key, Encoding, PrivateFormat, NoEncryption
)
import base64
key_obj = load_pem_private_key(private_key.encode(), password=None)
der = key_obj.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
return base64.urlsafe_b64encode(der).rstrip(b'=').decode()
return private_key
def _send_one(sub: dict, private_key: str, push_payload: dict, mailbox_address: str):
from pywebpush import webpush, WebPushException
from app.core.database import get_standalone_db
now_iso = datetime.now(timezone.utc).isoformat()
db = get_standalone_db()
try:
webpush(
subscription_info={
"endpoint": sub['endpoint'],
"keys": {"p256dh": sub['p256dh'], "auth": sub['auth']},
},
data=json.dumps(push_payload),
vapid_private_key=private_key,
vapid_claims={"sub": "mailto:push@dockflare.local"},
)
try:
db.execute(
"UPDATE push_subscriptions SET last_attempted_at=?, last_success_at=?, fail_count=0 WHERE id=?",
(now_iso, now_iso, sub['id']),
)
db.commit()
except Exception:
log.exception("Failed to update tracking for subscription %s", sub['id'])
except WebPushException as e:
if e.response is not None and e.response.status_code in (404, 410):
try:
db.execute("DELETE FROM push_subscriptions WHERE id=?", (sub['id'],))
db.commit()
log.info("Removed stale push subscription for %s", mailbox_address)
except Exception:
log.exception("Failed to delete stale subscription %s", sub['id'])
else:
try:
db.execute(
"UPDATE push_subscriptions SET last_attempted_at=?, fail_count=fail_count+1 WHERE id=?",
(now_iso, sub['id']),
)
db.commit()
except Exception:
log.exception("Failed to update fail_count for subscription %s", sub['id'])
log.warning("Push failed for %s: %s", mailbox_address, e)
except Exception:
try:
db.execute(
"UPDATE push_subscriptions SET last_attempted_at=?, fail_count=fail_count+1 WHERE id=?",
(now_iso, sub['id']),
)
db.commit()
except Exception:
log.exception("Failed to update fail_count for subscription %s", sub['id'])
log.exception("Push network error for %s", mailbox_address)
finally:
db.close()
def _dispatch(mailbox_address: str, payload: dict):
private_key = _get_private_key()
if not private_key:
return
from app.core.database import get_standalone_db
db = get_standalone_db()
try:
row = db.execute("""
SELECT m.notification_preview,
(SELECT COUNT(*) FROM messages msg
JOIN folders f ON msg.folder_id = f.id
WHERE msg.mailbox_address = m.address AND f.name='Inbox'
AND msg.is_read=0 AND msg.is_draft=0) AS unread_count
FROM mailboxes m WHERE m.address=?
""", (mailbox_address,)).fetchone()
unread_count = row['unread_count'] if row else 0
preview = row['notification_preview'] if row and row['notification_preview'] is not None else 1
push_payload = {
'message_id': payload.get('message_id'),
'mailbox': payload.get('mailbox'),
'unread_count': unread_count,
}
if preview:
push_payload['subject'] = payload.get('subject', '')
push_payload['from_name'] = payload.get('from_name', '')
subscriptions = list(db.execute(
"SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE mailbox_address=?",
(mailbox_address,),
).fetchall())
except Exception:
log.exception("Push dispatch error for %s", mailbox_address)
return
finally:
db.close()
if not subscriptions:
return
max_workers = min(len(subscriptions), 8)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
for sub in subscriptions:
executor.submit(_send_one, dict(sub), private_key, push_payload, mailbox_address)

View file

@ -0,0 +1,51 @@
import threading
import boto3
from botocore.config import Config as BotoConfig
from app.config import config
_client_cache = {}
_cache_lock = threading.Lock()
def get_r2_client(access_key_id, secret_access_key, endpoint_url):
cache_key = (endpoint_url, access_key_id)
with _cache_lock:
if cache_key not in _client_cache:
_client_cache[cache_key] = boto3.client(
's3',
endpoint_url=endpoint_url,
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
config=BotoConfig(signature_version='s3v4'),
region_name='auto'
)
return _client_cache[cache_key]
def _resolve(domain_cfg):
if domain_cfg:
return (
domain_cfg['r2_access_key_id'],
domain_cfg['r2_secret_access_key'],
domain_cfg['r2_endpoint_url'],
domain_cfg['r2_bucket'],
)
return (
config.R2_ACCESS_KEY_ID,
config.R2_SECRET_ACCESS_KEY,
config.R2_ENDPOINT_URL,
config.R2_BUCKET_NAME,
)
def fetch_email_from_r2(r2_key, domain_cfg=None):
ak, sk, ep, bucket = _resolve(domain_cfg)
s3 = get_r2_client(ak, sk, ep)
resp = s3.get_object(Bucket=bucket, Key=r2_key)
return resp['Body'].read()
def delete_from_r2(r2_key, domain_cfg=None):
ak, sk, ep, bucket = _resolve(domain_cfg)
s3 = get_r2_client(ak, sk, ep)
s3.delete_object(Bucket=bucket, 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()

View file

@ -0,0 +1,63 @@
import logging
import os
import threading
import time
log = logging.getLogger(__name__)
_INTERVAL = 7 * 24 * 3600
def _run_cleanup():
from app.core.database import get_standalone_db
db = get_standalone_db()
try:
cur = db.execute("""
DELETE FROM push_subscriptions
WHERE last_success_at IS NULL AND created_at < datetime('now', '-60 days')
""")
if cur.rowcount:
log.info("Cleanup: removed %d never-fired subscriptions", cur.rowcount)
cur = db.execute("""
DELETE FROM push_subscriptions
WHERE last_success_at IS NOT NULL AND last_success_at < datetime('now', '-60 days')
""")
if cur.rowcount:
log.info("Cleanup: removed %d inactive subscriptions", cur.rowcount)
cur = db.execute("""
DELETE FROM push_subscriptions WHERE fail_count >= 5
""")
if cur.rowcount:
log.info("Cleanup: removed %d high-failure subscriptions", cur.rowcount)
db.commit()
except Exception:
log.exception("Cleanup: error during subscription purge")
finally:
try:
db.execute("PRAGMA wal_checkpoint(PASSIVE)")
db.execute("PRAGMA optimize")
except Exception:
log.exception("Cleanup: error during WAL maintenance")
db.close()
def _scheduler_loop():
while True:
time.sleep(_INTERVAL)
log.info("Scheduler: running push subscription cleanup")
try:
_run_cleanup()
except Exception:
log.exception("Scheduler: unhandled error in cleanup run")
def start_scheduler():
if os.environ.get('DISABLE_SCHEDULER'):
return
t = threading.Thread(target=_scheduler_loop, daemon=True, name="push-cleanup-scheduler")
t.start()
log.info("Scheduler: push subscription cleanup scheduled every 7 days")

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)

239
mail-manager/entrypoint.py Normal file
View file

@ -0,0 +1,239 @@
import os
import time
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
log = logging.getLogger('mail-manager')
def bootstrap():
master_url = os.environ.get('DOCKFLARE_MASTER_URL', '').rstrip('/')
if not master_url:
log.warning("DOCKFLARE_MASTER_URL not set, skipping config bootstrap")
return None
import requests
bootstrap_secret = os.environ.get('INTERNAL_BOOTSTRAP_SECRET', '')
headers = {'X-Bootstrap-Token': bootstrap_secret} if bootstrap_secret else {}
for attempt in range(15):
try:
r = requests.get(
f"{master_url}/email/internal/config",
headers=headers,
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')
os.environ['VAPID_PRIVATE_KEY'] = data.get('vapid_private_key', '')
os.environ['VAPID_PUBLIC_KEY'] = data.get('vapid_public_key', '')
domains = data.get('domains', {})
if not domains:
log.warning("No domains in bootstrap config")
log.info("Config bootstrapped from DockFlare Master")
else:
log.info("DockFlare Master has no email config yet, starting unconfigured")
return data
except Exception as e:
log.info("Bootstrap attempt %d/15 failed: %s", attempt + 1, e)
time.sleep(2)
log.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):
log.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():
quota_bytes = mbox.get('quota_bytes', 10737418240)
existed = conn.execute(
"SELECT 1 FROM mailboxes WHERE address=?", (address,)
).fetchone()
conn.execute(
"INSERT INTO mailboxes (address, display_name, domain, created_at, is_active, quota_bytes) VALUES (?, ?, ?, ?, 1, ?) "
"ON CONFLICT(address) DO UPDATE SET display_name=excluded.display_name, quota_bytes=excluded.quota_bytes",
(address, mbox.get('display_name', ''), zone_name, now, quota_bytes),
)
if not existed:
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()
log.info("Mailbox sync complete")
except Exception as e:
log.error("Mailbox sync failed: %s", e)
finally:
conn.close()
def _sync_domains(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):
log.warning("DB not found during domain 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():
conn.execute("""
INSERT INTO domain_configs (
domain_name, webhook_secret, r2_bucket, r2_access_key_id,
r2_secret_access_key, r2_endpoint_url, outbound_worker_url,
outbound_auth_secret, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(domain_name) DO UPDATE SET
webhook_secret=excluded.webhook_secret,
r2_bucket=excluded.r2_bucket,
r2_access_key_id=excluded.r2_access_key_id,
r2_secret_access_key=excluded.r2_secret_access_key,
r2_endpoint_url=excluded.r2_endpoint_url,
outbound_worker_url=excluded.outbound_worker_url,
outbound_auth_secret=excluded.outbound_auth_secret,
updated_at=excluded.updated_at
""", (
zone_name,
d.get('webhook_secret', ''),
d.get('r2_bucket', ''),
d.get('r2_access_key_id', ''),
d.get('r2_secret_access_key', ''),
d.get('r2_endpoint_url', ''),
d.get('outbound_worker_url', ''),
d.get('outbound_auth_secret', ''),
now,
))
conn.commit()
log.info("Domain config sync complete (%d domains)", len(bootstrap_data.get('domains', {})))
except Exception as e:
log.error("Domain config sync failed: %s", e)
finally:
conn.close()
def _cleanup_stale_mailboxes(bootstrap_data):
if not bootstrap_data or not bootstrap_data.get('configured'):
return
domains = bootstrap_data.get('domains', {})
total_expected = sum(len(d.get('mailboxes', {})) for d in domains.values())
if total_expected < 1:
return
import sqlite3, shutil
mail_data_path = os.environ.get('MAIL_DATA_PATH', '/data')
db_path = os.path.join(mail_data_path, 'db', 'mail.db')
att_path = os.path.join(mail_data_path, 'attachments')
if not os.path.exists(db_path):
return
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
try:
for zone_name, d in domains.items():
expected = set(d.get('mailboxes', {}).keys())
db_rows = conn.execute(
"SELECT address FROM mailboxes WHERE domain=?", (zone_name,)
).fetchall()
for row in db_rows:
if row['address'] not in expected:
msg_rows = conn.execute(
"SELECT id FROM messages WHERE mailbox_address=? AND has_attachments=1",
(row['address'],),
).fetchall()
for m in msg_rows:
shutil.rmtree(os.path.join(att_path, str(m['id'])), ignore_errors=True)
conn.execute("DELETE FROM mailboxes WHERE address=?", (row['address'],))
log.info("Removed stale mailbox: %s", row['address'])
conn.commit()
except Exception as e:
log.error("Stale mailbox cleanup failed: %s", e)
finally:
conn.close()
def _heal_filesystem():
import sqlite3, shutil
mail_data_path = os.environ.get('MAIL_DATA_PATH', '/data')
db_path = os.path.join(mail_data_path, 'db', 'mail.db')
att_path = os.path.join(mail_data_path, 'attachments')
if not os.path.exists(att_path) or not os.path.exists(db_path):
return
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
for name in os.listdir(att_path):
dir_path = os.path.join(att_path, name)
if not os.path.isdir(dir_path):
continue
try:
msg_id = int(name)
except ValueError:
shutil.rmtree(dir_path, ignore_errors=True)
log.info("Purged non-integer attachment dir: %s", name)
continue
if not conn.execute("SELECT 1 FROM messages WHERE id=?", (msg_id,)).fetchone():
shutil.rmtree(dir_path, ignore_errors=True)
log.info("Purged orphan attachment dir: %s", name)
rows = conn.execute("SELECT id FROM messages WHERE has_attachments=1").fetchall()
for row in rows:
if not os.path.isdir(os.path.join(att_path, str(row['id']))):
conn.execute("UPDATE messages SET has_attachments=0 WHERE id=?", (row['id'],))
conn.commit()
except Exception as e:
log.error("Filesystem self-healing failed: %s", e)
finally:
conn.close()
bootstrap_data = bootstrap()
from waitress import serve
from app import create_app
app = create_app()
_sync_mailboxes(bootstrap_data)
_sync_domains(bootstrap_data)
_cleanup_stale_mailboxes(bootstrap_data)
_heal_filesystem()
log.info("Starting mail-manager on port 8025")
serve(app, host='0.0.0.0', port=8025, threads=16, connection_limit=100, channel_timeout=30)

View file

@ -0,0 +1,10 @@
flask==3.1.3
flask-limiter==4.1.1
waitress==3.0.2
boto3==1.42.83
PyJWT[crypto]==2.12.1
cryptography==46.0.7
python-dateutil==2.9.0.post0
nh3==0.2.21
requests==2.33.1
pywebpush==2.0.0

7
webmail/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Unignore src/lib — root .gitignore excludes lib/ (Python convention)
!src/lib/
!src/lib/**
# Exclude compiled artifacts
src/lib/*.js
src/lib/*.js.map

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,58 @@
#!/bin/sh
MASTER_URL="${DOCKFLARE_MASTER_URL:-}"
INTERNAL_URL="${DOCKFLARE_INTERNAL_URL:-http://dockflare:5000}"
echo "{\"masterUrl\": \"${MASTER_URL}\"}" > /usr/share/nginx/html/config.json
cat > /etc/nginx/conf.d/default.conf << EOF
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' https://static.cloudflareinsights.com; worker-src 'self'; connect-src 'self' https://fcm.googleapis.com https://*.googleapis.com https://cloudflareinsights.com;";
location /api/ {
proxy_pass http://dockflare-mail-manager:8025/api/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
location = /email/auth/login {
proxy_pass ${INTERNAL_URL}/email/auth/login;
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP \$http_cf_connecting_ip;
}
location = /sw.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache";
add_header Service-Worker-Allowed "/";
}
location = /manifest.webmanifest {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache";
types { application/manifest+json webmanifest; }
}
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";
}
}
EOF
exec nginx -g "daemon off;"

19
webmail/index.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<title>DockFlare Mail</title>
<link rel="apple-touch-icon" href="/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/favicon/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/favicon/android-chrome-512x512.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png">
<link rel="shortcut icon" href="/favicon/favicon.ico">
</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 "connect-src *; default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; font-src 'self' data: https://r2cdn.perplexity.ai https://rsms.me;" always;
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";
}
}

8413
webmail/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

52
webmail/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "dockflare-webmail",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-color": "^2.27.2",
"@tiptap/extension-font-family": "^2.27.2",
"@tiptap/extension-highlight": "^2.27.2",
"@tiptap/extension-image": "^2.6.0",
"@tiptap/extension-link": "^2.6.0",
"@tiptap/extension-placeholder": "^2.6.0",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/extension-typography": "^2.6.0",
"@tiptap/extension-underline": "^2.27.2",
"@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"
},
"overrides": {
"picomatch": "4.0.4",
"brace-expansion": "5.0.5",
"yaml": "2.8.3"
},
"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",
"vite-plugin-pwa": "^0.21.0",
"vue-tsc": "^2.1.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0"
}
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline — DockFlare Mail</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.card {
text-align: center;
padding: 2.5rem 2rem;
border: 1px solid #1e293b;
border-radius: 0.5rem;
max-width: 340px;
width: 100%;
}
h1 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; }
p { font-size: 0.875rem; color: #94a3b8; line-height: 1.5; }
</style>
</head>
<body>
<div class="card">
<h1>You're offline</h1>
<p>DockFlare Mail will reconnect when your network is available.</p>
</div>
</body>
</html>

24
webmail/src/App.vue Normal file
View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { useRegisterSW } from 'virtual:pwa-register/vue'
const { needRefresh, updateServiceWorker } = useRegisterSW()
</script>
<template>
<RouterView />
<Transition name="slide-up">
<div
v-if="needRefresh"
class="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border bg-card px-4 py-3 shadow-lg text-card-foreground"
>
<span class="text-sm">Update available</span>
<button
class="text-sm font-medium underline underline-offset-2"
@click="updateServiceWorker()"
>
Reload
</button>
</div>
</Transition>
</template>

76
webmail/src/App.vue.js Normal file
View file

@ -0,0 +1,76 @@
/// <reference types="../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { RouterView } from 'vue-router';
import { useRegisterSW } from 'virtual:pwa-register/vue';
const { needRefresh, updateServiceWorker } = useRegisterSW();
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));
const __VLS_4 = {}.Transition;
/** @type {[typeof __VLS_components.Transition, typeof __VLS_components.Transition, ]} */ ;
// @ts-ignore
const __VLS_5 = __VLS_asFunctionalComponent(__VLS_4, new __VLS_4({
name: "slide-up",
}));
const __VLS_6 = __VLS_5({
name: "slide-up",
}, ...__VLS_functionalComponentArgsRest(__VLS_5));
__VLS_7.slots.default;
if (__VLS_ctx.needRefresh) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border bg-card px-4 py-3 shadow-lg text-card-foreground" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-sm" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.needRefresh))
return;
__VLS_ctx.updateServiceWorker();
} },
...{ class: "text-sm font-medium underline underline-offset-2" },
});
}
var __VLS_7;
/** @type {__VLS_StyleScopedClasses['fixed']} */ ;
/** @type {__VLS_StyleScopedClasses['bottom-4']} */ ;
/** @type {__VLS_StyleScopedClasses['right-4']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-3']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-lg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-card']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-lg']} */ ;
/** @type {__VLS_StyleScopedClasses['text-card-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
/** @type {__VLS_StyleScopedClasses['underline']} */ ;
/** @type {__VLS_StyleScopedClasses['underline-offset-2']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
RouterView: RouterView,
needRefresh: needRefresh,
updateServiceWorker: updateServiceWorker,
};
},
});
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":"AAwBA,8EAA8E;AAE9E,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAExD,MAAM,EAAE,WAAW,EAAE,mBAAmB,EAAE,GAAG,aAAa,EAAE,CAAA;AAC5D,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,MAAM,OAAO,GAAI,EAA+G,CAAC,UAAU,CAAC;AAC5I,yFAAyF,CAAA,CAAC;AAC1F,aAAa;AACb,MAAM,OAAO,GAAG,2BAA2B,CAAC,OAAO,EAAE,IAAI,OAAO,CAAC;IACjE,IAAI,EAAE,UAAU;CACf,CAAC,CAAC,CAAC;AACJ,MAAM,OAAO,GAAG,OAAO,CAAC;IACxB,IAAI,EAAE,UAAU;CACf,EAAE,GAAG,iCAAiC,CAAC,OAAO,CAAC,CAAC,CAAC;AAClD,OAAO,CAAC,KAAM,CAAC,OAAO,CAAC;AACvB,IAAI,SAAS,CAAC,WAAW,EAAE,CAAC;IAC5B,yBAAyB,CAAC,uBAAuB,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC;QACpF,GAAG,EAAE,KAAK,EAAE,wHAAwH,EAAE;KACrI,CAAC,CAAC;IACH,yBAAyB,CAAC,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC;QACtF,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE;KACtB,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,WAAW,CAAC;oBAAE,OAAO;gBACrC,SAAS,CAAC,mBAAmB,EAAE,CAAC;YAChC,CAAC,EAAC;QACF,GAAG,EAAE,KAAK,EAAE,kDAAkD,EAAE;KAC/D,CAAC,CAAC;AACH,CAAC;AACD,IAAI,OAA0E,CAAC;AAC/E,gDAAgD,CAAA,CAAC;AACjD,mDAAmD,CAAA,CAAC;AACpD,kDAAkD,CAAA,CAAC;AACnD,+CAA+C,CAAA,CAAC;AAChD,+CAA+C,CAAA,CAAC;AAChD,uDAAuD,CAAA,CAAC;AACxD,gDAAgD,CAAA,CAAC;AACjD,qDAAqD,CAAA,CAAC;AACtD,iDAAiD,CAAA,CAAC;AAClD,kDAAkD,CAAA,CAAC;AACnD,+CAA+C,CAAA,CAAC;AAChD,+CAA+C,CAAA,CAAC;AAChD,oDAAoD,CAAA,CAAC;AACrD,+DAA+D,CAAA,CAAC;AAChE,kDAAkD,CAAA,CAAC;AACnD,kDAAkD,CAAA,CAAC;AACnD,sDAAsD,CAAA,CAAC;AACvD,oDAAoD,CAAA,CAAC;AACrD,6DAA6D,CAAA,CAAC;AAM9D,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;YAC3C,WAAW,EAAE,WAAiC;YAC9C,mBAAmB,EAAE,mBAAiD;SACrE,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"}

13
webmail/src/api/auth.js Normal file
View file

@ -0,0 +1,13 @@
import apiClient from './client';
export const authApi = {
checkAuth: () => apiClient.get('/auth/me'),
loginWithPassword: async (email, password) => {
const response = await fetch('/email/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return response.json();
},
};
//# 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;IAE1C,iBAAiB,EAAE,KAAK,EAAE,KAAa,EAAE,QAAgB,EAAE,EAAE;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,mBAAmB,EAAE;YAChD,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;SAC1C,CAAC,CAAA;QACF,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAA;IACxB,CAAC;CACF,CAAA"}

27
webmail/src/api/auth.ts Normal file
View file

@ -0,0 +1,27 @@
import apiClient from './client'
export const authApi = {
checkAuth: () => apiClient.get('/auth/me'),
loginWithPassword: async (email: string, password: string) => {
const response = await fetch('/email/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
return response.json()
},
changePassword: async (currentPassword: string, newPassword: string) => {
const token = localStorage.getItem('jwt_token')
const response = await fetch('/email/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
})
return response.json()
},
}

29
webmail/src/api/client.js Normal file
View file

@ -0,0 +1,29 @@
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}`;
}
// Let the browser set Content-Type (with boundary) for FormData
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
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,gEAAgE;IAChE,IAAI,MAAM,CAAC,IAAI,YAAY,QAAQ,EAAE,CAAC;QACpC,OAAO,MAAM,CAAC,OAAO,CAAC,cAAc,CAAC,CAAA;IACvC,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"}

38
webmail/src/api/client.ts Normal file
View file

@ -0,0 +1,38 @@
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}`
}
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
})
apiClient.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('jwt_token')
import('../stores/auth').then(({ useAuthStore }) => {
const authStore = useAuthStore()
authStore.token = ''
})
router.push('/login')
}
return Promise.reject(error)
}
)
export default apiClient

23
webmail/src/api/mail.js Normal file
View file

@ -0,0 +1,23 @@
import apiClient from './client';
export const mailApi = {
getMailboxes: () => apiClient.get('/mailboxes'),
getFolders: (address) => apiClient.get(`/mailboxes/${address}/folders`),
createFolder: (address, name, color) => apiClient.post(`/mailboxes/${address}/folders`, { name, color }),
deleteFolder: (address, id) => apiClient.delete(`/mailboxes/${address}/folders/${id}`),
emptyFolder: (address, id) => apiClient.delete(`/mailboxes/${address}/folders/${id}/empty`),
renameFolder: (address, id, name, color) => apiClient.patch(`/mailboxes/${address}/folders/${id}`, { name, color }),
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}`),
moveMessages: (address, data) => apiClient.post(`/mailboxes/${address}/messages/move`, data),
markMessages: (address, data) => apiClient.post(`/mailboxes/${address}/messages/mark`, data),
sendMessage: (address, data) => apiClient.post(`/mailboxes/${address}/send`, data),
searchMessages: (address, params) => apiClient.get(`/mailboxes/${address}/search`, { params }),
getMailboxStatus: () => apiClient.get('/mailboxes/status'),
getAttachmentUrl: (id) => `/api/v1/attachments/${id}/download`,
downloadAttachment: (id) => apiClient.get(`/attachments/${id}/download`, { responseType: 'blob' }).then(r => r.data),
createDraft: (address, data) => apiClient.post(`/mailboxes/${address}/drafts`, data),
updateDraft: (address, id, data) => apiClient.put(`/mailboxes/${address}/drafts/${id}`, data),
};
//# 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,YAAY,EAAE,CAAC,OAAe,EAAE,IAAY,EAAE,KAAc,EAAE,EAAE,CAC9D,SAAS,CAAC,IAAI,CAAC,cAAc,OAAO,UAAU,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAClE,YAAY,EAAE,CAAC,OAAe,EAAE,EAAU,EAAE,EAAE,CAC5C,SAAS,CAAC,MAAM,CAAC,cAAc,OAAO,YAAY,EAAE,EAAE,CAAC;IACzD,WAAW,EAAE,CAAC,OAAe,EAAE,EAAU,EAAE,EAAE,CAC3C,SAAS,CAAC,MAAM,CAAC,cAAc,OAAO,YAAY,EAAE,QAAQ,CAAC;IAC/D,YAAY,EAAE,CAAC,OAAe,EAAE,EAAU,EAAE,IAAY,EAAE,KAAc,EAAE,EAAE,CAC1E,SAAS,CAAC,KAAK,CAAC,cAAc,OAAO,YAAY,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IACzE,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,YAAY,EAAE,CAAC,OAAe,EAAE,IAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,cAAc,OAAO,gBAAgB,EAAE,IAAI,CAAC;IACzG,YAAY,EAAE,CAAC,OAAe,EAAE,IAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,cAAc,OAAO,gBAAgB,EAAE,IAAI,CAAC;IACzG,WAAW,EAAE,CAAC,OAAe,EAAE,IAAoC,EAAE,EAAE,CACrE,SAAS,CAAC,IAAI,CAAC,cAAc,OAAO,OAAO,EAAE,IAAI,CAAC;IACpD,cAAc,EAAE,CAAC,OAAe,EAAE,MAAW,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,cAAc,OAAO,SAAS,EAAE,EAAE,MAAM,EAAE,CAAC;IAC3G,gBAAgB,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,mBAAmB,CAAC;IAC1D,gBAAgB,EAAE,CAAC,EAAU,EAAE,EAAE,CAAC,uBAAuB,EAAE,WAAW;IACtE,kBAAkB,EAAE,CAAC,EAAmB,EAAE,EAAE,CAC1C,SAAS,CAAC,GAAG,CAAC,gBAAgB,EAAE,WAAW,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAY,CAAC;IAClG,WAAW,EAAE,CAAC,OAAe,EAAE,IAAyB,EAAE,EAAE,CAC1D,SAAS,CAAC,IAAI,CAAC,cAAc,OAAO,SAAS,EAAE,IAAI,CAAC;IACtD,WAAW,EAAE,CAAC,OAAe,EAAE,EAAU,EAAE,IAAyB,EAAE,EAAE,CACtE,SAAS,CAAC,GAAG,CAAC,cAAc,OAAO,WAAW,EAAE,EAAE,EAAE,IAAI,CAAC;CAC5D,CAAA"}

40
webmail/src/api/mail.ts Normal file
View file

@ -0,0 +1,40 @@
import apiClient from './client'
export const mailApi = {
getMailboxes: () => apiClient.get('/mailboxes'),
getFolders: (address: string) => apiClient.get(`/mailboxes/${address}/folders`),
createFolder: (address: string, name: string, color?: string) =>
apiClient.post(`/mailboxes/${address}/folders`, { name, color }),
deleteFolder: (address: string, id: number) =>
apiClient.delete(`/mailboxes/${address}/folders/${id}`),
emptyFolder: (address: string, id: number) =>
apiClient.delete(`/mailboxes/${address}/folders/${id}/empty`),
renameFolder: (address: string, id: number, name: string, color?: string) =>
apiClient.patch(`/mailboxes/${address}/folders/${id}`, { name, color }),
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}`),
moveMessages: (address: string, data: any) => apiClient.post(`/mailboxes/${address}/messages/move`, data),
markMessages: (address: string, data: any) => apiClient.post(`/mailboxes/${address}/messages/mark`, data),
sendMessage: (address: string, data: FormData | Record<string, any>) =>
apiClient.post(`/mailboxes/${address}/send`, data),
searchMessages: (address: string, params: any) => apiClient.get(`/mailboxes/${address}/search`, { params }),
getMailboxStatus: () => apiClient.get('/mailboxes/status'),
getMailboxPreferences: (address: string) => apiClient.get(`/mailboxes/${address}/preferences`),
updateMailboxPreferences: (address: string, data: Record<string, any>) =>
apiClient.patch(`/mailboxes/${address}/preferences`, data),
getAttachmentUrl: (id: string) => `/api/v1/attachments/${id}/download`,
downloadAttachment: (id: number | string) =>
apiClient.get(`/attachments/${id}/download`, { responseType: 'blob' }).then(r => r.data as Blob),
createDraft: (address: string, data: Record<string, any>) =>
apiClient.post(`/mailboxes/${address}/drafts`, data),
updateDraft: (address: string, id: number, data: Record<string, any>) =>
apiClient.put(`/mailboxes/${address}/drafts/${id}`, data),
getAutoResponder: (address: string) =>
apiClient.get(`/mailboxes/${address}/auto-responder`),
setAutoResponder: (address: string, data: Record<string, any>) =>
apiClient.post(`/mailboxes/${address}/auto-responder`, data),
deleteAutoResponder: (address: string) =>
apiClient.delete(`/mailboxes/${address}/auto-responder`),
}

View file

@ -0,0 +1,82 @@
@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;
}
}
@media print {
body * {
visibility: hidden;
}
#print-message-area, #print-message-area * {
visibility: visible;
}
#print-message-area {
position: absolute;
left: 0;
top: 0;
width: 100%;
margin: 0;
padding: 0;
}
.print-hide, .print-hide * {
display: none !important;
}
#print-message-area .flex-1 {
overflow: visible !important;
}
}

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Paperclip, Download } from 'lucide-vue-next'
import { mailApi } from '../../api/mail'
import Button from '../ui/Button.vue'
defineProps({
attachments: { type: Array, default: () => [] },
})
const downloading = ref<number | null>(null)
const formatSize = (bytes: number) => {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
return `${bytes} B`
}
const download = async (att: any) => {
downloading.value = att.id
try {
const blob = await mailApi.downloadAttachment(att.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = att.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
console.error('Download failed', e)
} finally {
downloading.value = null
}
}
</script>
<template>
<div v-if="attachments && attachments.length > 0" class="flex flex-wrap gap-2 border-t p-4">
<div
v-for="att in (attachments as any[])"
:key="att.id"
class="flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-sm"
>
<Paperclip class="size-4 text-muted-foreground shrink-0" />
<span class="truncate max-w-[180px]">{{ att.filename }}</span>
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ formatSize(att.size_bytes) }}</span>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
:disabled="downloading === att.id"
@click="download(att)"
>
<Download class="size-4" />
</Button>
</div>
</div>
</template>

View file

@ -0,0 +1,157 @@
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { ref } from 'vue';
import { Paperclip, Download } from 'lucide-vue-next';
import { mailApi } from '../../api/mail';
import Button from '../ui/Button.vue';
const __VLS_props = defineProps({
attachments: { type: Array, default: () => [] },
});
const downloading = ref(null);
const formatSize = (bytes) => {
if (bytes >= 1_048_576)
return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024)
return `${Math.round(bytes / 1024)} KB`;
return `${bytes} B`;
};
const download = async (att) => {
downloading.value = att.id;
try {
const blob = await mailApi.downloadAttachment(att.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = att.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
catch (e) {
console.error('Download failed', e);
}
finally {
downloading.value = null;
}
};
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-lg border bg-muted/40 px-3 py-2 text-sm" },
});
const __VLS_0 = {}.Paperclip;
/** @type {[typeof __VLS_components.Paperclip, ]} */ ;
// @ts-ignore
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({
...{ class: "size-4 text-muted-foreground shrink-0" },
}));
const __VLS_2 = __VLS_1({
...{ class: "size-4 text-muted-foreground shrink-0" },
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "truncate max-w-[180px]" },
});
(att.filename);
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-xs text-muted-foreground whitespace-nowrap" },
});
(__VLS_ctx.formatSize(att.size_bytes));
/** @type {[typeof Button, typeof Button, ]} */ ;
// @ts-ignore
const __VLS_4 = __VLS_asFunctionalComponent(Button, new Button({
...{ 'onClick': {} },
variant: "ghost",
size: "sm",
...{ class: "h-7 w-7 p-0" },
disabled: (__VLS_ctx.downloading === att.id),
}));
const __VLS_5 = __VLS_4({
...{ 'onClick': {} },
variant: "ghost",
size: "sm",
...{ class: "h-7 w-7 p-0" },
disabled: (__VLS_ctx.downloading === att.id),
}, ...__VLS_functionalComponentArgsRest(__VLS_4));
let __VLS_7;
let __VLS_8;
let __VLS_9;
const __VLS_10 = {
onClick: (...[$event]) => {
if (!(__VLS_ctx.attachments && __VLS_ctx.attachments.length > 0))
return;
__VLS_ctx.download(att);
}
};
__VLS_6.slots.default;
const __VLS_11 = {}.Download;
/** @type {[typeof __VLS_components.Download, ]} */ ;
// @ts-ignore
const __VLS_12 = __VLS_asFunctionalComponent(__VLS_11, new __VLS_11({
...{ class: "size-4" },
}));
const __VLS_13 = __VLS_12({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_12));
var __VLS_6;
}
}
/** @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-lg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-muted/40']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['truncate']} */ ;
/** @type {__VLS_StyleScopedClasses['max-w-[180px]']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['whitespace-nowrap']} */ ;
/** @type {__VLS_StyleScopedClasses['h-7']} */ ;
/** @type {__VLS_StyleScopedClasses['w-7']} */ ;
/** @type {__VLS_StyleScopedClasses['p-0']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
Paperclip: Paperclip,
Download: Download,
Button: Button,
downloading: downloading,
formatSize: formatSize,
download: download,
};
},
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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,441 @@
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue'
import {
Paperclip, X, Bold as BoldIcon, Italic as ItalicIcon, Link2, List as ListIcon, ListOrdered, Minus,
Underline as UnderlineIcon, AlignLeft as AlignLeftIcon, AlignCenter as AlignCenterIcon, AlignRight as AlignRightIcon, AlignJustify as AlignJustifyIcon,
Quote as QuoteIcon, RemoveFormatting, Baseline, Trash2, Strikethrough as StrikethroughIcon, Type, BookmarkCheck, Maximize2, Minimize2
} from 'lucide-vue-next'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import LinkExtension from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import Typography from '@tiptap/extension-typography'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Color from '@tiptap/extension-color'
import TextStyle from '@tiptap/extension-text-style'
import Highlight from '@tiptap/extension-highlight'
import FontFamily from '@tiptap/extension-font-family'
import { mailApi } from '../../api/mail'
import { useMailStore } from '../../stores/mail'
import Button from '../ui/Button.vue'
import Input from '../ui/Input.vue'
defineProps({ panelMode: { type: Boolean, default: false } })
const store = useMailStore()
const to = ref('')
const subject = ref('')
const attachments = ref<File[]>([])
const sending = ref(false)
const savingDraft = ref(false)
const savedDraft = ref(false)
const draftId = ref<number | null>(null)
const error = ref('')
const minimized = ref(false)
const showFormatting = ref(false)
const quotedHtml = ref('')
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
const editor = useEditor({
extensions: [
StarterKit,
LinkExtension.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Write your message…' }),
Typography,
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,
Color,
Highlight.configure({ multicolor: true }),
FontFamily,
],
editorProps: {
attributes: { class: 'tiptap-editor' },
},
onUpdate: ({ editor }) => {
if (store.composeDefaults !== null) {
store.composeDefaults = { ...store.composeDefaults, body: editor.getHTML() }
} else {
store.composeDefaults = { body: editor.getHTML() }
}
},
})
const reset = () => {
to.value = ''
subject.value = ''
attachments.value = []
error.value = ''
minimized.value = false
draftId.value = null
savedDraft.value = false
quotedHtml.value = ''
editor.value?.commands.clearContent()
store.composeDefaults = null
}
watch(() => store.isComposeOpen, async (open) => {
if (open && store.composeDefaults) {
to.value = store.composeDefaults.to || ''
subject.value = store.composeDefaults.subject || ''
quotedHtml.value = store.composeDefaults.quotedHtml || ''
if (store.composeDefaults.draftId) {
draftId.value = store.composeDefaults.draftId
}
minimized.value = false
await nextTick()
if (store.composeDefaults.body) {
editor.value?.commands.setContent(store.composeDefaults.body)
} else {
editor.value?.commands.clearContent()
}
} else if (!open) {
reset()
}
}, { immediate: true })
watch(to, (val) => {
if (store.composeDefaults !== null) {
store.composeDefaults = { ...store.composeDefaults, to: val }
}
})
watch(subject, (val) => {
if (store.composeDefaults !== null) {
store.composeDefaults = { ...store.composeDefaults, subject: val }
}
})
watch(quotedHtml, (val) => {
if (store.composeDefaults !== null) {
store.composeDefaults = { ...store.composeDefaults, quotedHtml: val }
}
})
onUnmounted(() => editor.value?.destroy())
const close = () => {
store.isComposeOpen = false
store.isComposeFullView = false
}
const toggleFullView = () => {
store.isComposeFullView = !store.isComposeFullView
minimized.value = false
}
const discardDraft = async () => {
if (draftId.value && store.currentMailbox) {
try {
await mailApi.deleteMessage(store.currentMailbox, String(draftId.value))
const res = await mailApi.getFolders(store.currentMailbox)
store.folders = res.data
if (store.currentFolder === 'Drafts') {
store.messages = store.messages.filter((m: any) => m.id !== draftId.value)
}
} catch (e) {}
}
close()
}
const toggleMinimize = () => {
minimized.value = !minimized.value
}
const onFileChange = (e: Event) => {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of Array.from(input.files)) {
if (!attachments.value.find(f => f.name === file.name && f.size === file.size)) {
attachments.value.push(file)
}
}
input.value = ''
}
const removeAttachment = (index: number) => {
attachments.value.splice(index, 1)
}
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const setLink = () => {
const prev = editor.value?.getAttributes('link').href || ''
const url = window.prompt('Enter URL', prev)
if (url === null) return
if (url === '') {
editor.value?.chain().focus().unsetLink().run()
} else {
editor.value?.chain().focus().setLink({ href: url }).run()
}
}
const saveDraft = async () => {
if (!store.currentMailbox || !editor.value) return
savingDraft.value = true
error.value = ''
try {
const payload = {
to: to.value ? to.value.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
subject: subject.value,
html_body: editor.value.getHTML() + (quotedHtml.value || ''),
text_body: editor.value.getText(),
}
if (draftId.value) {
await mailApi.updateDraft(store.currentMailbox, draftId.value, payload)
} else {
const res = await mailApi.createDraft(store.currentMailbox, payload)
draftId.value = res.data.id
}
savedDraft.value = true
setTimeout(() => { savedDraft.value = false }, 2000)
const res = await mailApi.getFolders(store.currentMailbox)
store.folders = res.data
} catch (e: any) {
error.value = e?.response?.data?.error || 'Failed to save draft.'
} finally {
savingDraft.value = false
}
}
const send = async () => {
if (!store.currentMailbox || !editor.value) return
const totalSize = attachments.value.reduce((sum, f) => sum + f.size, 0)
if (totalSize > MAX_ATTACHMENT_BYTES) {
error.value = `Attachments exceed 10 MB limit (${formatBytes(totalSize)} total).`
return
}
sending.value = true
error.value = ''
try {
const html = editor.value.getHTML() + (quotedHtml.value || '')
const text = editor.value.getText()
const formData = new FormData()
formData.append('to', to.value)
formData.append('subject', subject.value)
formData.append('html', html)
formData.append('text', text)
for (const file of attachments.value) {
formData.append('attachments', file)
}
await mailApi.sendMessage(store.currentMailbox, formData)
const fRes = await mailApi.getFolders(store.currentMailbox)
store.folders = fRes.data
close()
} catch (e: any) {
error.value = e?.response?.data?.error || 'Failed to send. Please try again.'
} finally {
sending.value = false
}
}
const fonts = [
{ label: 'Sans Serif', value: 'Inter, ui-sans-serif, system-ui, sans-serif' },
{ label: 'Serif', value: 'ui-serif, Georgia, serif' },
{ label: 'Monospace', value: 'ui-monospace, Consolas, monospace' },
{ label: 'Comic Sans', value: '"Comic Sans MS", "Comic Sans", cursive' },
{ label: 'Garamond', value: 'Garamond, serif' },
{ label: 'Trebuchet', value: '"Trebuchet MS", sans-serif' },
]
const setFont = (e: Event) => {
const target = e.target as HTMLSelectElement
if (target.value) {
editor.value?.chain().focus().setFontFamily(target.value).run()
} else {
editor.value?.chain().focus().unsetFontFamily().run()
}
}
const setColor = (e: Event) => {
const target = e.target as HTMLInputElement
editor.value?.chain().focus().setColor(target.value).run()
}
const setHighlight = (e: Event) => {
const target = e.target as HTMLInputElement
editor.value?.chain().focus().setHighlight({ color: target.value }).run()
}
</script>
<template>
<div
v-if="store.isComposeOpen && (panelMode || !store.isComposeFullView)"
:class="panelMode
? 'flex flex-col h-full w-full bg-background'
: 'fixed bottom-0 right-6 z-50 flex flex-col rounded-t-xl shadow-2xl border border-border bg-background'"
:style="!panelMode ? (minimized ? 'width:320px' : 'width:560px') : ''"
>
<!-- Panel mode title bar -->
<div v-if="panelMode" class="h-[52px] flex items-center gap-2 px-4 border-b border-border flex-shrink-0">
<span class="flex-1 text-base font-semibold truncate">{{ subject || 'New Message' }}</span>
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" title="Pop out" @click="toggleFullView">
<Minimize2 class="size-4" />
</button>
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive transition-colors" title="Close" @click="close">
<X class="size-4" />
</button>
</div>
<!-- Popup mode title bar -->
<div v-else class="flex items-center gap-2 rounded-t-xl bg-primary px-4 py-2.5 cursor-pointer select-none" @click="toggleMinimize">
<span class="flex-1 text-sm font-semibold text-primary-foreground truncate">{{ subject || 'New Message' }}</span>
<button type="button" class="rounded p-0.5 text-primary-foreground/70 hover:text-primary-foreground hover:bg-white/10 transition-colors" title="Full view" @click.stop="toggleFullView">
<Maximize2 class="size-4" />
</button>
<button type="button" class="rounded p-0.5 text-primary-foreground/70 hover:text-primary-foreground hover:bg-white/10 transition-colors" title="Minimize" @click.stop="toggleMinimize">
<Minus class="size-4" />
</button>
<button type="button" class="rounded p-0.5 text-primary-foreground/70 hover:text-primary-foreground hover:bg-white/10 transition-colors" title="Close" @click.stop="close">
<X class="size-4" />
</button>
</div>
<!-- Body always visible in panel mode, hidden when minimized in popup mode -->
<div
v-show="panelMode || !minimized"
:class="panelMode ? 'flex flex-col flex-1 overflow-hidden' : 'flex flex-col flex-1 overflow-hidden max-h-[80vh]'"
>
<!-- Fields -->
<div class="flex flex-col border-b border-border flex-shrink-0">
<input v-model="to" placeholder="To" class="w-full border-b border-border px-4 py-2 text-sm bg-background text-foreground placeholder:text-muted-foreground focus:outline-none" />
<input v-model="subject" placeholder="Subject" class="w-full px-4 py-2 text-sm bg-background text-foreground placeholder:text-muted-foreground focus:outline-none" />
</div>
<!-- Editor -->
<EditorContent :editor="editor" class="compose-editor flex-1" />
<!-- Attachment list -->
<div v-if="attachments.length" class="flex flex-wrap gap-1.5 border-t border-border px-4 py-2 bg-muted/30 flex-shrink-0">
<div v-for="(file, i) in attachments" :key="i" class="flex items-center gap-1 bg-muted text-muted-foreground text-xs rounded px-2 py-1 border border-border">
<span class="truncate max-w-[140px]">{{ file.name }}</span>
<span class="text-muted-foreground/60">({{ formatBytes(file.size) }})</span>
<button type="button" @click="removeAttachment(i)" class="ml-1 hover:text-destructive"><X :size="12" /></button>
</div>
</div>
<div v-if="quotedHtml" class="border-t border-border px-4 py-1.5 text-xs text-muted-foreground flex-shrink-0 select-none">
Quoted message included
</div>
<div v-if="error" class="px-4 py-1 text-xs text-red-500 flex-shrink-0">{{ error }}</div>
<!-- Formatting Toolbar (Collapsible) -->
<div v-if="showFormatting" class="flex flex-wrap items-center gap-1 border-t border-border bg-muted/30 px-3 py-1.5 flex-shrink-0">
<select @change="setFont" class="text-xs bg-transparent border-none focus:ring-0 text-foreground cursor-pointer mr-1 max-w-[100px]">
<option value="">Default Font</option>
<option v-for="font in fonts" :key="font.value" :value="font.value">{{ font.label }}</option>
</select>
<div class="mx-1 h-4 w-px bg-border" />
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive('bold') ? 'bg-accent' : ''" @click="editor?.chain().focus().toggleBold().run()" title="Bold"><BoldIcon class="size-3.5" /></button>
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive('italic') ? 'bg-accent' : ''" @click="editor?.chain().focus().toggleItalic().run()" title="Italic"><ItalicIcon class="size-3.5" /></button>
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive('underline') ? 'bg-accent' : ''" @click="editor?.chain().focus().toggleUnderline().run()" title="Underline"><UnderlineIcon class="size-3.5" /></button>
<div class="mx-1 h-4 w-px bg-border" />
<div class="relative group flex items-center p-1 rounded hover:bg-accent cursor-pointer" title="Text Color">
<Baseline class="size-3.5 text-foreground" />
<input type="color" @input="setColor" class="absolute inset-0 opacity-0 cursor-pointer w-full h-full" />
</div>
<div class="relative flex items-center p-1 rounded hover:bg-accent cursor-pointer" title="Background Color">
<span class="text-xs font-bold leading-none bg-foreground text-background px-0.5 rounded-sm">ab</span>
<input type="color" @input="setHighlight" class="absolute inset-0 opacity-0 cursor-pointer w-full h-full" />
</div>
<div class="mx-1 h-4 w-px bg-border" />
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive({ textAlign: 'left' }) ? 'bg-accent' : ''" @click="editor?.chain().focus().setTextAlign('left').run()" title="Align left"><AlignLeftIcon class="size-3.5" /></button>
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive({ textAlign: 'center' }) ? 'bg-accent' : ''" @click="editor?.chain().focus().setTextAlign('center').run()" title="Align center"><AlignCenterIcon class="size-3.5" /></button>
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive({ textAlign: 'right' }) ? 'bg-accent' : ''" @click="editor?.chain().focus().setTextAlign('right').run()" title="Align right"><AlignRightIcon class="size-3.5" /></button>
<div class="mx-1 h-4 w-px bg-border" />
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive('bulletList') ? 'bg-accent' : ''" @click="editor?.chain().focus().toggleBulletList().run()" title="Bullet list"><ListIcon class="size-3.5" /></button>
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive('orderedList') ? 'bg-accent' : ''" @click="editor?.chain().focus().toggleOrderedList().run()" title="Ordered list"><ListOrdered class="size-3.5" /></button>
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" :class="editor?.isActive('blockquote') ? 'bg-accent' : ''" @click="editor?.chain().focus().toggleBlockquote().run()" title="Quote"><QuoteIcon class="size-3.5" /></button>
<div class="mx-1 h-4 w-px bg-border" />
<button type="button" class="rounded p-1 hover:bg-accent transition-colors" @click="editor?.chain().focus().unsetAllMarks().clearNodes().run()" title="Remove formatting"><RemoveFormatting class="size-3.5" /></button>
</div>
<!-- Bottom Action Bar -->
<div class="flex items-center justify-between gap-2 border-t border-border px-4 py-2.5 flex-shrink-0 bg-background">
<div class="flex items-center gap-1">
<Button as="button" type="button" size="sm" class="rounded-full px-5 font-semibold tracking-wide" @click.prevent="send" :disabled="sending || !to">
{{ sending ? 'Sending…' : 'Send' }}
</Button>
<button type="button" class="ml-1 rounded p-1.5 transition-colors" :class="savedDraft ? 'text-green-500' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'" :disabled="savingDraft" title="Save draft" @click="saveDraft">
<BookmarkCheck class="size-4" />
</button>
<button type="button" class="ml-1 rounded p-1.5 hover:bg-accent transition-colors" :class="showFormatting ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" @click="showFormatting = !showFormatting" title="Formatting options">
<Type class="size-4" />
</button>
<label class="cursor-pointer rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" title="Attach files">
<Paperclip class="size-4" />
<input type="file" multiple class="hidden" @change="onFileChange" />
</label>
<button type="button" class="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" @click="setLink" title="Insert link">
<Link2 class="size-4" />
</button>
</div>
<button type="button" class="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors" :title="draftId ? 'Delete draft' : 'Discard'" @click="discardDraft">
<Trash2 class="size-4" />
</button>
</div>
</div>
</div>
</template>
<style>
/* Tiptap editor inside compose */
.compose-editor {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.compose-editor .tiptap-editor {
padding: 0.75rem 1rem;
flex: 1;
min-height: 200px;
overflow-y: auto;
font-size: 0.875rem;
outline: none;
line-height: 1.5;
}
.compose-editor .tiptap-editor p {
margin: 0 0 0.35em;
}
.compose-editor .tiptap-editor p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
pointer-events: none;
float: left;
height: 0;
}
.compose-editor .tiptap-editor ul,
.compose-editor .tiptap-editor ol {
padding-left: 1.25rem;
margin: 0 0 0.35em;
}
.compose-editor .tiptap-editor blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1rem;
margin: 0 0 0.35em;
color: hsl(var(--muted-foreground));
}
.compose-editor .tiptap-editor a {
color: hsl(var(--primary));
text-decoration: underline;
cursor: pointer;
}
/* Pop-up animation */
.compose-pop-enter-active { transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.18s ease; }
.compose-pop-leave-active { transition: transform 0.14s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.14s ease; }
.compose-pop-enter-from { transform: translateY(24px) scale(0.95); opacity: 0; }
.compose-pop-leave-to { transform: translateY(24px) scale(0.95); opacity: 0; }
</style>

File diff suppressed because it is too large Load diff

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,72 @@
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.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) => {
const val = e.target.value;
text.value = val;
store.composeBody = val;
emit('update:modelValue', val);
};
const getHTML = () => text.value;
const __VLS_exposed = { getHTML };
defineExpose(__VLS_exposed);
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 overflow-hidden" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.textarea)({
...{ onInput: (__VLS_ctx.onInput) },
value: (__VLS_ctx.text),
placeholder: "Write your message...",
...{ class: "flex-1 p-4 text-sm resize-none focus:outline-none min-h-[160px]" },
});
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['overflow-hidden']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['resize-none']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['min-h-[160px]']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
text: text,
onInput: onInput,
};
},
emits: {},
props: {
modelValue: { type: String, default: '' }
},
});
export default (await import('vue')).defineComponent({
setup() {
return {
...__VLS_exposed,
};
},
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":"AAuCA,oFAAoF;AAEpF,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,KAAK,CAAA;AAChC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,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;AAC/C,MAAM,KAAK,GAAG,YAAY,EAAE,CAAA;AAE5B,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;AAElC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE;IACpC,IAAI,CAAC,KAAK,GAAG,GAAG,CAAA;IAChB,KAAK,CAAC,WAAW,GAAG,GAAG,CAAA;AACzB,CAAC,CAAC,CAAA;AAEF,MAAM,OAAO,GAAG,CAAC,CAAQ,EAAE,EAAE;IAC3B,MAAM,GAAG,GAAI,CAAC,CAAC,MAA8B,CAAC,KAAK,CAAA;IACnD,IAAI,CAAC,KAAK,GAAG,GAAG,CAAA;IAChB,KAAK,CAAC,WAAW,GAAG,GAAG,CAAA;IACvB,IAAI,CAAC,mBAAmB,EAAE,GAAG,CAAC,CAAA;AAChC,CAAC,CAAA;AAED,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAA;AAChC,MAAM,aAAa,GAAG,EAAE,OAAO,EAAE,CAAC;AAClC,YAAY,CAAC,aAAa,CAAC,CAAA;AAC3B,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,iDAAiD,EAAE;CAC9D,CAAC,CAAC;AACH,yBAAyB,CAAC,uBAAuB,CAAC,QAAQ,CAAC,CAAC;IAC5D,GAAG,EAAE,OAAO,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,EAAC;IAClC,KAAK,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC;IACvB,WAAW,EAAE,uBAAuB;IACpC,GAAG,EAAE,KAAK,EAAE,iEAAiE,EAAE;CAC9E,CAAC,CAAC;AACH,+CAA+C,CAAA,CAAC;AAChD,mDAAmD,CAAA,CAAC;AACpD,iDAAiD,CAAA,CAAC;AAClD,qDAAqD,CAAA,CAAC;AACtD,0DAA0D,CAAA,CAAC;AAC3D,iDAAiD,CAAA,CAAC;AAClD,8CAA8C,CAAA,CAAC;AAC/C,kDAAkD,CAAA,CAAC;AACnD,sDAAsD,CAAA,CAAC;AACvD,6DAA6D,CAAA,CAAC;AAC9D,wDAAwD,CAAA,CAAC;AAOzD,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO;YACP,IAAI,EAAE,IAAmB;YACzB,OAAO,EAAE,OAAyB;SACjC,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;YACP,GAAG,aAAa;SACf,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,286 @@
<script setup lang="ts">
import { computed, ref, type Component } from 'vue'
import {
Inbox, FileText, Send, Trash2, AlertCircle, Archive, Folder,
FolderPlus, X,
} from 'lucide-vue-next'
import { TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal } from 'radix-vue'
import { cn } from '../../lib/utils'
import { useMailStore } from '../../stores/mail'
import { mailApi } from '../../api/mail'
defineProps({
isCollapsed: { type: Boolean, default: false },
})
const store = useMailStore()
const iconMap: Record<string, Component> = {
Inbox, Drafts: FileText, Sent: Send,
Trash: Trash2, Spam: AlertCircle, Junk: AlertCircle,
Archive,
}
const PALETTE = [
'#ef4444', '#f97316', '#eab308', '#22c55e',
'#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6',
]
const getIcon = (name: string): Component => iconMap[name] || Folder
const selectFolder = (name: string) => {
store.currentFolder = name
store.currentMessage = null
store.viewMode = 'split'
}
// New folder creation
const showNewFolder = ref(false)
const newFolderName = ref('')
const newFolderColor = ref('')
const creatingFolder = ref(false)
const startNewFolder = () => {
newFolderName.value = ''
newFolderColor.value = ''
showNewFolder.value = true
}
const cancelNewFolder = () => {
showNewFolder.value = false
}
const confirmNewFolder = async () => {
const name = newFolderName.value.trim()
if (!name || !store.currentMailbox) return
creatingFolder.value = true
try {
await mailApi.createFolder(store.currentMailbox, name, newFolderColor.value || undefined)
const res = await mailApi.getFolders(store.currentMailbox)
store.folders = res.data
showNewFolder.value = false
} catch {
store.showToast('Failed to create folder')
} finally {
creatingFolder.value = false
}
}
// Folder delete
const deleteFolder = async (f: any) => {
if (!store.currentMailbox) return
if (!confirm(`Delete folder "${f.name}"? All messages inside will be deleted.`)) return
try {
await mailApi.deleteFolder(store.currentMailbox, f.id)
const res = await mailApi.getFolders(store.currentMailbox)
store.folders = res.data
if (store.currentFolder === f.name) {
store.currentFolder = store.folders[0]?.name || ''
}
} catch {
store.showToast('Failed to delete folder')
}
}
// Folder rename / colour edit
const editingFolder = ref<any>(null)
const editName = ref('')
const editColor = ref('')
const startEdit = (f: any) => {
editingFolder.value = f
editName.value = f.name
editColor.value = f.color || ''
}
const cancelEdit = () => {
editingFolder.value = null
}
const confirmEdit = async () => {
if (!editingFolder.value || !store.currentMailbox) return
const name = editName.value.trim()
if (!name) return
try {
await mailApi.renameFolder(store.currentMailbox, editingFolder.value.id, name, editColor.value || undefined)
const res = await mailApi.getFolders(store.currentMailbox)
store.folders = res.data
if (store.currentFolder === editingFolder.value.name && name !== editingFolder.value.name) {
store.currentFolder = name
}
editingFolder.value = null
} catch {
store.showToast('Failed to rename folder')
}
}
</script>
<template>
<div
:data-collapsed="isCollapsed"
class="group flex flex-1 flex-col justify-between py-2 overflow-y-auto"
>
<nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
<template v-for="f in store.folders" :key="f.name">
<!-- Collapsed icon-only -->
<TooltipRoot v-if="isCollapsed" :delay-duration="0">
<TooltipTrigger as-child>
<button
:class="cn(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
store.currentFolder === f.name
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
: 'text-muted-foreground',
)"
@click="selectFolder(f.name)"
>
<component :is="getIcon(f.name)" class="size-4" :style="f.color && store.currentFolder !== f.name ? `color:${f.color}` : ''" />
<span class="sr-only">{{ f.name }}</span>
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="right"
class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md flex items-center gap-4"
>
{{ f.name }}
<span v-if="f.total_count > 0" class="ml-auto text-muted-foreground flex gap-1">
<span v-if="f.unread_count" class="font-bold">{{ f.unread_count }} /</span>
<span>{{ f.total_count }}</span>
</span>
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<!-- Expanded row inline edit mode -->
<div v-else-if="editingFolder?.id === f.id" class="rounded-md border bg-muted p-2 flex flex-col gap-2">
<input
v-model="editName"
class="w-full rounded border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="confirmEdit"
@keyup.escape="cancelEdit"
autofocus
/>
<div class="flex gap-1 flex-wrap">
<button
v-for="c in PALETTE" :key="c"
class="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
:style="`background:${c}; border-color:${editColor === c ? '#000' : 'transparent'}`"
@click="editColor = editColor === c ? '' : c"
/>
<button
class="h-5 w-5 rounded-full border-2 text-xs flex items-center justify-center text-muted-foreground hover:bg-accent"
:style="`border-color:${!editColor ? '#888' : 'transparent'}`"
title="No colour"
@click="editColor = ''"
></button>
</div>
<div class="flex gap-1 justify-end">
<button class="text-xs px-2 py-1 rounded hover:bg-accent text-muted-foreground" @click="cancelEdit">Cancel</button>
<button class="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90" @click="confirmEdit">Save</button>
</div>
</div>
<!-- Expanded normal row -->
<div
v-else
:class="cn(
'group/row relative flex items-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
store.currentFolder === f.name
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
: 'transparent',
)"
>
<button
class="flex flex-1 items-center gap-3 px-3 py-2 text-left min-w-0"
@click="selectFolder(f.name)"
>
<component :is="getIcon(f.name)" class="size-4 flex-shrink-0" :style="f.color ? `color:${f.color}` : ''" />
<span class="truncate">{{ f.name }}</span>
<span
v-if="f.total_count > 0"
:class="cn(
'ml-auto text-xs flex-shrink-0 flex gap-1',
store.currentFolder === f.name ? 'text-primary-foreground' : 'text-muted-foreground',
)"
>
<span v-if="f.unread_count" class="font-bold">{{ f.unread_count }} /</span>
<span>{{ f.total_count }}</span>
</span>
</button>
<!-- Custom folder actions absolutely positioned so they don't affect count alignment -->
<div
v-if="!f.system_folder"
:class="cn(
'absolute right-1 flex gap-0.5 opacity-0 group-hover/row:opacity-100 transition-opacity rounded px-0.5',
store.currentFolder === f.name ? 'bg-primary' : 'bg-accent',
)"
>
<button
class="p-1 rounded hover:bg-accent/80"
title="Rename / recolour"
@click.stop="startEdit(f)"
>
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button
class="p-1 rounded hover:text-destructive"
title="Delete folder"
@click.stop="deleteFolder(f)"
>
<Trash2 class="size-3" />
</button>
</div>
</div>
</template>
<!-- New folder inline form (expanded only) -->
<div v-if="showNewFolder && !isCollapsed" class="rounded-md border bg-muted p-2 flex flex-col gap-2 mt-1">
<input
v-model="newFolderName"
placeholder="Folder name"
class="w-full rounded border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="confirmNewFolder"
@keyup.escape="cancelNewFolder"
autofocus
/>
<div class="flex gap-1 flex-wrap">
<button
v-for="c in PALETTE" :key="c"
class="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
:style="`background:${c}; border-color:${newFolderColor === c ? '#000' : 'transparent'}`"
@click="newFolderColor = newFolderColor === c ? '' : c"
/>
<button
class="h-5 w-5 rounded-full border-2 text-xs flex items-center justify-center text-muted-foreground hover:bg-accent"
:style="`border-color:${!newFolderColor ? '#888' : 'transparent'}`"
title="No colour"
@click="newFolderColor = ''"
></button>
</div>
<div class="flex gap-1 justify-end">
<button class="text-xs px-2 py-1 rounded hover:bg-accent text-muted-foreground" @click="cancelNewFolder">Cancel</button>
<button
class="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
:disabled="creatingFolder || !newFolderName.trim()"
@click="confirmNewFolder"
>
{{ creatingFolder ? '…' : 'Create' }}
</button>
</div>
</div>
<!-- Add folder button (expanded only) -->
<button
v-if="!isCollapsed"
class="flex items-center gap-2 rounded-md px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors mt-1"
@click="startNewFolder"
>
<FolderPlus class="size-3" />
New folder
</button>
</nav>
</div>
</template>

View file

@ -0,0 +1,608 @@
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { ref } from 'vue';
import { Inbox, FileText, Send, Trash2, AlertCircle, Archive, Folder, FolderPlus, } from 'lucide-vue-next';
import { TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal } from 'radix-vue';
import { cn } from '../../lib/utils';
import { useMailStore } from '../../stores/mail';
import { mailApi } from '../../api/mail';
const __VLS_props = defineProps({
isCollapsed: { type: Boolean, default: false },
});
const store = useMailStore();
const iconMap = {
Inbox, Drafts: FileText, Sent: Send,
Trash: Trash2, Spam: AlertCircle, Junk: AlertCircle,
Archive,
};
const PALETTE = [
'#ef4444', '#f97316', '#eab308', '#22c55e',
'#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6',
];
const getIcon = (name) => iconMap[name] || Folder;
const selectFolder = (name) => {
store.currentFolder = name;
store.currentMessage = null;
store.viewMode = 'split';
};
// ── New folder creation ──────────────────────────────────────────────
const showNewFolder = ref(false);
const newFolderName = ref('');
const newFolderColor = ref('');
const creatingFolder = ref(false);
const startNewFolder = () => {
newFolderName.value = '';
newFolderColor.value = '';
showNewFolder.value = true;
};
const cancelNewFolder = () => {
showNewFolder.value = false;
};
const confirmNewFolder = async () => {
const name = newFolderName.value.trim();
if (!name || !store.currentMailbox)
return;
creatingFolder.value = true;
try {
await mailApi.createFolder(store.currentMailbox, name, newFolderColor.value || undefined);
const res = await mailApi.getFolders(store.currentMailbox);
store.folders = res.data;
showNewFolder.value = false;
}
catch (e) {
console.error('Failed to create folder', e);
}
finally {
creatingFolder.value = false;
}
};
// ── Folder delete ────────────────────────────────────────────────────
const deleteFolder = async (f) => {
if (!store.currentMailbox)
return;
if (!confirm(`Delete folder "${f.name}"? All messages inside will be deleted.`))
return;
try {
await mailApi.deleteFolder(store.currentMailbox, f.id);
const res = await mailApi.getFolders(store.currentMailbox);
store.folders = res.data;
if (store.currentFolder === f.name) {
store.currentFolder = store.folders[0]?.name || '';
}
}
catch (e) {
console.error('Failed to delete folder', e);
}
};
// ── Folder rename / colour edit ──────────────────────────────────────
const editingFolder = ref(null);
const editName = ref('');
const editColor = ref('');
const startEdit = (f) => {
editingFolder.value = f;
editName.value = f.name;
editColor.value = f.color || '';
};
const cancelEdit = () => {
editingFolder.value = null;
};
const confirmEdit = async () => {
if (!editingFolder.value || !store.currentMailbox)
return;
const name = editName.value.trim();
if (!name)
return;
try {
await mailApi.renameFolder(store.currentMailbox, editingFolder.value.id, name, editColor.value || undefined);
const res = await mailApi.getFolders(store.currentMailbox);
store.folders = res.data;
if (store.currentFolder === editingFolder.value.name && name !== editingFolder.value.name) {
store.currentFolder = name;
}
editingFolder.value = null;
}
catch (e) {
console.error('Failed to rename folder', e);
}
};
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;
let __VLS_directives;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
'data-collapsed': (__VLS_ctx.isCollapsed),
...{ class: "group flex flex-1 flex-col justify-between py-2 overflow-y-auto" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.nav, __VLS_intrinsicElements.nav)({
...{ class: "grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2" },
});
for (const [f] of __VLS_getVForSourceType((__VLS_ctx.store.folders))) {
(f.name);
if (__VLS_ctx.isCollapsed) {
const __VLS_0 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({
delayDuration: (0),
}));
const __VLS_2 = __VLS_1({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
__VLS_3.slots.default;
const __VLS_4 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_5 = __VLS_asFunctionalComponent(__VLS_4, new __VLS_4({
asChild: true,
}));
const __VLS_6 = __VLS_5({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_5));
__VLS_7.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.isCollapsed))
return;
__VLS_ctx.selectFolder(f.name);
} },
...{ class: (__VLS_ctx.cn('inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground', __VLS_ctx.store.currentFolder === f.name
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
: 'text-muted-foreground')) },
});
const __VLS_8 = ((__VLS_ctx.getIcon(f.name)));
// @ts-ignore
const __VLS_9 = __VLS_asFunctionalComponent(__VLS_8, new __VLS_8({
...{ class: "size-4" },
...{ style: (f.color && __VLS_ctx.store.currentFolder !== f.name ? `color:${f.color}` : '') },
}));
const __VLS_10 = __VLS_9({
...{ class: "size-4" },
...{ style: (f.color && __VLS_ctx.store.currentFolder !== f.name ? `color:${f.color}` : '') },
}, ...__VLS_functionalComponentArgsRest(__VLS_9));
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "sr-only" },
});
(f.name);
var __VLS_7;
const __VLS_12 = {}.TooltipPortal;
/** @type {[typeof __VLS_components.TooltipPortal, typeof __VLS_components.TooltipPortal, ]} */ ;
// @ts-ignore
const __VLS_13 = __VLS_asFunctionalComponent(__VLS_12, new __VLS_12({}));
const __VLS_14 = __VLS_13({}, ...__VLS_functionalComponentArgsRest(__VLS_13));
__VLS_15.slots.default;
const __VLS_16 = {}.TooltipContent;
/** @type {[typeof __VLS_components.TooltipContent, typeof __VLS_components.TooltipContent, ]} */ ;
// @ts-ignore
const __VLS_17 = __VLS_asFunctionalComponent(__VLS_16, new __VLS_16({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md flex items-center gap-4" },
}));
const __VLS_18 = __VLS_17({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md flex items-center gap-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_17));
__VLS_19.slots.default;
(f.name);
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "ml-auto text-muted-foreground flex gap-1" },
});
if (f.unread_count) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "font-bold" },
});
(f.unread_count);
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
(f.total_count || 0);
var __VLS_19;
var __VLS_15;
var __VLS_3;
}
else if (__VLS_ctx.editingFolder?.id === f.id) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "rounded-md border bg-muted p-2 flex flex-col gap-2" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
...{ onKeyup: (__VLS_ctx.confirmEdit) },
...{ onKeyup: (__VLS_ctx.cancelEdit) },
...{ class: "w-full rounded border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring" },
autofocus: true,
});
(__VLS_ctx.editName);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex gap-1 flex-wrap" },
});
for (const [c] of __VLS_getVForSourceType((__VLS_ctx.PALETTE))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.isCollapsed))
return;
if (!(__VLS_ctx.editingFolder?.id === f.id))
return;
__VLS_ctx.editColor = __VLS_ctx.editColor === c ? '' : c;
} },
key: (c),
...{ class: "h-5 w-5 rounded-full border-2 transition-transform hover:scale-110" },
...{ style: (`background:${c}; border-color:${__VLS_ctx.editColor === c ? '#000' : 'transparent'}`) },
});
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.isCollapsed))
return;
if (!(__VLS_ctx.editingFolder?.id === f.id))
return;
__VLS_ctx.editColor = '';
} },
...{ class: "h-5 w-5 rounded-full border-2 text-xs flex items-center justify-center text-muted-foreground hover:bg-accent" },
...{ style: (`border-color:${!__VLS_ctx.editColor ? '#888' : 'transparent'}`) },
title: "No colour",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex gap-1 justify-end" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.cancelEdit) },
...{ class: "text-xs px-2 py-1 rounded hover:bg-accent text-muted-foreground" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.confirmEdit) },
...{ class: "text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90" },
});
}
else {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: (__VLS_ctx.cn('group/row relative flex items-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground', __VLS_ctx.store.currentFolder === f.name
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
: 'transparent')) },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.isCollapsed))
return;
if (!!(__VLS_ctx.editingFolder?.id === f.id))
return;
__VLS_ctx.selectFolder(f.name);
} },
...{ class: "flex flex-1 items-center gap-3 px-3 py-2 text-left min-w-0" },
});
const __VLS_20 = ((__VLS_ctx.getIcon(f.name)));
// @ts-ignore
const __VLS_21 = __VLS_asFunctionalComponent(__VLS_20, new __VLS_20({
...{ class: "size-4 flex-shrink-0" },
...{ style: (f.color ? `color:${f.color}` : '') },
}));
const __VLS_22 = __VLS_21({
...{ class: "size-4 flex-shrink-0" },
...{ style: (f.color ? `color:${f.color}` : '') },
}, ...__VLS_functionalComponentArgsRest(__VLS_21));
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "truncate" },
});
(f.name);
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: (__VLS_ctx.cn('ml-auto text-xs flex-shrink-0 flex gap-1', __VLS_ctx.store.currentFolder === f.name ? 'text-primary-foreground' : 'text-muted-foreground')) },
});
if (f.unread_count) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "font-bold" },
});
(f.unread_count);
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
(f.total_count || 0);
if (!f.system_folder) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: (__VLS_ctx.cn('absolute right-1 flex gap-0.5 opacity-0 group-hover/row:opacity-100 transition-opacity rounded px-0.5', __VLS_ctx.store.currentFolder === f.name ? 'bg-primary' : 'bg-accent')) },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.isCollapsed))
return;
if (!!(__VLS_ctx.editingFolder?.id === f.id))
return;
if (!(!f.system_folder))
return;
__VLS_ctx.startEdit(f);
} },
...{ class: "p-1 rounded hover:bg-accent/80" },
title: "Rename / recolour",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
...{ class: "size-3" },
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
'stroke-width': "2",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.isCollapsed))
return;
if (!!(__VLS_ctx.editingFolder?.id === f.id))
return;
if (!(!f.system_folder))
return;
__VLS_ctx.deleteFolder(f);
} },
...{ class: "p-1 rounded hover:text-destructive" },
title: "Delete folder",
});
const __VLS_24 = {}.Trash2;
/** @type {[typeof __VLS_components.Trash2, ]} */ ;
// @ts-ignore
const __VLS_25 = __VLS_asFunctionalComponent(__VLS_24, new __VLS_24({
...{ class: "size-3" },
}));
const __VLS_26 = __VLS_25({
...{ class: "size-3" },
}, ...__VLS_functionalComponentArgsRest(__VLS_25));
}
}
}
if (__VLS_ctx.showNewFolder && !__VLS_ctx.isCollapsed) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "rounded-md border bg-muted p-2 flex flex-col gap-2 mt-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
...{ onKeyup: (__VLS_ctx.confirmNewFolder) },
...{ onKeyup: (__VLS_ctx.cancelNewFolder) },
placeholder: "Folder name",
...{ class: "w-full rounded border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring" },
autofocus: true,
});
(__VLS_ctx.newFolderName);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex gap-1 flex-wrap" },
});
for (const [c] of __VLS_getVForSourceType((__VLS_ctx.PALETTE))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.showNewFolder && !__VLS_ctx.isCollapsed))
return;
__VLS_ctx.newFolderColor = __VLS_ctx.newFolderColor === c ? '' : c;
} },
key: (c),
...{ class: "h-5 w-5 rounded-full border-2 transition-transform hover:scale-110" },
...{ style: (`background:${c}; border-color:${__VLS_ctx.newFolderColor === c ? '#000' : 'transparent'}`) },
});
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex gap-1 justify-end" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.cancelNewFolder) },
...{ class: "text-xs px-2 py-1 rounded hover:bg-accent text-muted-foreground" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.confirmNewFolder) },
...{ class: "text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" },
disabled: (__VLS_ctx.creatingFolder || !__VLS_ctx.newFolderName.trim()),
});
(__VLS_ctx.creatingFolder ? '…' : 'Create');
}
if (!__VLS_ctx.isCollapsed) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.startNewFolder) },
...{ class: "flex items-center gap-2 rounded-md px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors mt-1" },
});
const __VLS_28 = {}.FolderPlus;
/** @type {[typeof __VLS_components.FolderPlus, ]} */ ;
// @ts-ignore
const __VLS_29 = __VLS_asFunctionalComponent(__VLS_28, new __VLS_28({
...{ class: "size-3" },
}));
const __VLS_30 = __VLS_29({
...{ class: "size-3" },
}, ...__VLS_functionalComponentArgsRest(__VLS_29));
}
/** @type {__VLS_StyleScopedClasses['group']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-between']} */ ;
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
/** @type {__VLS_StyleScopedClasses['overflow-y-auto']} */ ;
/** @type {__VLS_StyleScopedClasses['grid']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['group-[[data-collapsed=true]]:justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['group-[[data-collapsed=true]]:px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['sr-only']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-4']} */ ;
/** @type {__VLS_StyleScopedClasses['ml-auto']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['font-bold']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['p-2']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-background']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:ring-1']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:ring-ring']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-wrap']} */ ;
/** @type {__VLS_StyleScopedClasses['h-5']} */ ;
/** @type {__VLS_StyleScopedClasses['w-5']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-full']} */ ;
/** @type {__VLS_StyleScopedClasses['border-2']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-transform']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:scale-110']} */ ;
/** @type {__VLS_StyleScopedClasses['h-5']} */ ;
/** @type {__VLS_StyleScopedClasses['w-5']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-full']} */ ;
/** @type {__VLS_StyleScopedClasses['border-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-end']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['text-primary-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-primary/90']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-3']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-left']} */ ;
/** @type {__VLS_StyleScopedClasses['min-w-0']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['truncate']} */ ;
/** @type {__VLS_StyleScopedClasses['font-bold']} */ ;
/** @type {__VLS_StyleScopedClasses['p-1']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent/80']} */ ;
/** @type {__VLS_StyleScopedClasses['size-3']} */ ;
/** @type {__VLS_StyleScopedClasses['p-1']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-destructive']} */ ;
/** @type {__VLS_StyleScopedClasses['size-3']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['p-2']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
/** @type {__VLS_StyleScopedClasses['mt-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-background']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:ring-1']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:ring-ring']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-wrap']} */ ;
/** @type {__VLS_StyleScopedClasses['h-5']} */ ;
/** @type {__VLS_StyleScopedClasses['w-5']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-full']} */ ;
/** @type {__VLS_StyleScopedClasses['border-2']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-transform']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:scale-110']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-end']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['text-primary-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-primary/90']} */ ;
/** @type {__VLS_StyleScopedClasses['disabled:opacity-50']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-accent-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['mt-1']} */ ;
/** @type {__VLS_StyleScopedClasses['size-3']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
Trash2: Trash2,
FolderPlus: FolderPlus,
TooltipRoot: TooltipRoot,
TooltipTrigger: TooltipTrigger,
TooltipContent: TooltipContent,
TooltipPortal: TooltipPortal,
cn: cn,
store: store,
PALETTE: PALETTE,
getIcon: getIcon,
selectFolder: selectFolder,
showNewFolder: showNewFolder,
newFolderName: newFolderName,
newFolderColor: newFolderColor,
creatingFolder: creatingFolder,
startNewFolder: startNewFolder,
cancelNewFolder: cancelNewFolder,
confirmNewFolder: confirmNewFolder,
deleteFolder: deleteFolder,
editingFolder: editingFolder,
editName: editName,
editColor: editColor,
startEdit: startEdit,
cancelEdit: cancelEdit,
confirmEdit: confirmEdit,
};
},
props: {
isCollapsed: { type: Boolean, default: false },
},
});
export default (await import('vue')).defineComponent({
setup() {
return {};
},
props: {
isCollapsed: { type: Boolean, default: false },
},
});
; /* PartiallyEnd: #4569/main.vue */
//# sourceMappingURL=FolderNav.vue.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,248 @@
<script setup lang="ts">
import {
SplitterGroup, SplitterPanel, SplitterResizeHandle,
TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal,
} from 'radix-vue'
import { defineAsyncComponent } from 'vue'
import { PenSquare, Sun, Moon, LogOut, Settings, Columns2, Maximize2 } from 'lucide-vue-next'
import { cn } from '../../lib/utils'
import Separator from '../ui/Separator.vue'
import MailboxSelector from './MailboxSelector.vue'
import FolderNav from './FolderNav.vue'
import MessageList from './MessageList.vue'
import MessageDisplay from './MessageDisplay.vue'
import ComposeDialog from './ComposeDialog.vue'
import { useMailStore } from '../../stores/mail'
import { useAuth } from '../../composables/useAuth'
const SettingsDialog = defineAsyncComponent(() => import('./SettingsDialog.vue'))
const store = useMailStore()
const { logout } = useAuth()
const onCollapse = () => { store.isCollapsed = true }
const onExpand = () => { store.isCollapsed = false }
const compose = () => {
store.composeDefaults = null
store.isComposeOpen = true
}
</script>
<template>
<TooltipProvider :delay-duration="0">
<SplitterGroup
id="mail-layout"
direction="horizontal"
class="h-screen w-screen items-stretch"
>
<SplitterPanel
id="sidebar"
:default-size="20"
:collapsed-size="4"
collapsible
:min-size="15"
:max-size="22"
:class="cn(
'flex flex-col',
store.isCollapsed && 'min-w-[50px] transition-all duration-300 ease-in-out',
)"
@collapse="onCollapse"
@expand="onExpand"
>
<!-- Single header row (same height as middle + right panel headers) -->
<div
:class="cn(
'h-[52px] flex items-center gap-1 px-2 flex-shrink-0',
store.isCollapsed ? 'flex-col justify-center py-1' : 'flex-row',
)"
>
<template v-if="!store.isCollapsed">
<!-- Mailbox selector fills available space -->
<div class="flex-1 min-w-0">
<MailboxSelector :is-collapsed="false" />
</div>
<!-- Compose -->
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-colors flex-shrink-0"
@click="compose"
>
<PenSquare class="size-4" />
Compose
</button>
</TooltipTrigger>
</TooltipRoot>
<!-- View Mode toggle -->
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
@click="store.toggleViewMode()"
>
<Columns2 v-if="store.viewMode === 'full'" class="size-4" />
<Maximize2 v-else class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="bottom" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
{{ store.viewMode === 'full' ? 'Split view' : 'Full view' }}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<!-- Theme toggle -->
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
@click="store.toggleTheme()"
>
<Sun v-if="store.isDark" class="size-4" />
<Moon v-else class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="bottom" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
{{ store.isDark ? 'Light mode' : 'Dark mode' }}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<!-- Settings -->
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
@click="store.isSettingsOpen = true"
>
<Settings class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="bottom" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
Settings
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<!-- Logout -->
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
@click="logout"
>
<LogOut class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="bottom" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
Logout
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</template>
<!-- Collapsed: stacked icon buttons -->
<template v-else>
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" @click="compose">
<PenSquare class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">Compose</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" @click="store.toggleTheme()">
<Sun v-if="store.isDark" class="size-4" />
<Moon v-else class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">{{ store.isDark ? 'Light mode' : 'Dark mode' }}</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" @click="store.isSettingsOpen = true">
<Settings class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">Settings</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" @click="logout">
<LogOut class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">Logout</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<!-- Collapsed mailbox icon below -->
<MailboxSelector :is-collapsed="true" />
</template>
</div>
<FolderNav :is-collapsed="store.isCollapsed" />
</SplitterPanel>
<SplitterResizeHandle
id="sidebar-handle"
class="self-stretch w-[3px] bg-transparent hover:bg-border active:bg-primary/40 transition-colors"
/>
<template v-if="store.viewMode === 'split'">
<SplitterPanel
id="mail-list"
:default-size="35"
:min-size="25"
class="flex flex-col overflow-hidden"
>
<MessageList />
</SplitterPanel>
<SplitterResizeHandle
id="display-handle"
class="self-stretch w-[3px] bg-transparent hover:bg-border active:bg-primary/40 transition-colors"
/>
<SplitterPanel
id="mail-display"
:default-size="45"
:min-size="30"
class="flex flex-col overflow-hidden"
>
<ComposeDialog v-if="store.isComposeOpen && store.isComposeFullView" :panel-mode="true" />
<MessageDisplay v-else :message="store.currentMessage ?? undefined" />
</SplitterPanel>
</template>
<template v-else>
<SplitterPanel
id="mail-content"
:default-size="80"
:min-size="30"
class="flex flex-col overflow-hidden"
>
<template v-if="store.isComposeOpen && store.isComposeFullView">
<ComposeDialog :panel-mode="true" />
</template>
<template v-else>
<MessageList v-if="!store.currentMessage" />
<MessageDisplay v-else :message="store.currentMessage ?? undefined" />
</template>
</SplitterPanel>
</template>
</SplitterGroup>
<ComposeDialog />
<SettingsDialog />
</TooltipProvider>
</template>

View file

@ -0,0 +1,873 @@
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { SplitterGroup, SplitterPanel, SplitterResizeHandle, TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal, } from 'radix-vue';
import { PenSquare, Sun, Moon, LogOut } from 'lucide-vue-next';
import { cn } from '../../lib/utils';
import MailboxSelector from './MailboxSelector.vue';
import FolderNav from './FolderNav.vue';
import MessageList from './MessageList.vue';
import MessageDisplay from './MessageDisplay.vue';
import ComposeDialog from './ComposeDialog.vue';
import { useMailStore } from '../../stores/mail';
import { useAuth } from '../../composables/useAuth';
const store = useMailStore();
const { logout } = useAuth();
const onCollapse = () => { store.isCollapsed = true; };
const onExpand = () => { store.isCollapsed = false; };
const compose = () => {
store.composeDefaults = null;
store.isComposeOpen = true;
};
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;
let __VLS_directives;
const __VLS_0 = {}.TooltipProvider;
/** @type {[typeof __VLS_components.TooltipProvider, typeof __VLS_components.TooltipProvider, ]} */ ;
// @ts-ignore
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({
delayDuration: (0),
}));
const __VLS_2 = __VLS_1({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
var __VLS_4 = {};
__VLS_3.slots.default;
const __VLS_5 = {}.SplitterGroup;
/** @type {[typeof __VLS_components.SplitterGroup, typeof __VLS_components.SplitterGroup, ]} */ ;
// @ts-ignore
const __VLS_6 = __VLS_asFunctionalComponent(__VLS_5, new __VLS_5({
id: "mail-layout",
direction: "horizontal",
...{ class: "h-screen w-screen items-stretch" },
}));
const __VLS_7 = __VLS_6({
id: "mail-layout",
direction: "horizontal",
...{ class: "h-screen w-screen items-stretch" },
}, ...__VLS_functionalComponentArgsRest(__VLS_6));
__VLS_8.slots.default;
const __VLS_9 = {}.SplitterPanel;
/** @type {[typeof __VLS_components.SplitterPanel, typeof __VLS_components.SplitterPanel, ]} */ ;
// @ts-ignore
const __VLS_10 = __VLS_asFunctionalComponent(__VLS_9, new __VLS_9({
...{ 'onCollapse': {} },
...{ 'onExpand': {} },
id: "sidebar",
defaultSize: (20),
collapsedSize: (4),
collapsible: true,
minSize: (15),
maxSize: (22),
...{ class: (__VLS_ctx.cn('flex flex-col', __VLS_ctx.store.isCollapsed && 'min-w-[50px] transition-all duration-300 ease-in-out')) },
}));
const __VLS_11 = __VLS_10({
...{ 'onCollapse': {} },
...{ 'onExpand': {} },
id: "sidebar",
defaultSize: (20),
collapsedSize: (4),
collapsible: true,
minSize: (15),
maxSize: (22),
...{ class: (__VLS_ctx.cn('flex flex-col', __VLS_ctx.store.isCollapsed && 'min-w-[50px] transition-all duration-300 ease-in-out')) },
}, ...__VLS_functionalComponentArgsRest(__VLS_10));
let __VLS_13;
let __VLS_14;
let __VLS_15;
const __VLS_16 = {
onCollapse: (__VLS_ctx.onCollapse)
};
const __VLS_17 = {
onExpand: (__VLS_ctx.onExpand)
};
__VLS_12.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: (__VLS_ctx.cn('h-[52px] flex items-center gap-1 px-2 flex-shrink-0', __VLS_ctx.store.isCollapsed ? 'flex-col justify-center py-1' : 'flex-row')) },
});
if (!__VLS_ctx.store.isCollapsed) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex-1 min-w-0" },
});
/** @type {[typeof MailboxSelector, ]} */ ;
// @ts-ignore
const __VLS_18 = __VLS_asFunctionalComponent(MailboxSelector, new MailboxSelector({
isCollapsed: (false),
}));
const __VLS_19 = __VLS_18({
isCollapsed: (false),
}, ...__VLS_functionalComponentArgsRest(__VLS_18));
const __VLS_21 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_22 = __VLS_asFunctionalComponent(__VLS_21, new __VLS_21({
delayDuration: (0),
}));
const __VLS_23 = __VLS_22({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_22));
__VLS_24.slots.default;
const __VLS_25 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_26 = __VLS_asFunctionalComponent(__VLS_25, new __VLS_25({
asChild: true,
}));
const __VLS_27 = __VLS_26({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_26));
__VLS_28.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.compose) },
...{ class: "flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-colors flex-shrink-0" },
});
const __VLS_29 = {}.PenSquare;
/** @type {[typeof __VLS_components.PenSquare, ]} */ ;
// @ts-ignore
const __VLS_30 = __VLS_asFunctionalComponent(__VLS_29, new __VLS_29({
...{ class: "size-4" },
}));
const __VLS_31 = __VLS_30({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_30));
var __VLS_28;
var __VLS_24;
const __VLS_33 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_34 = __VLS_asFunctionalComponent(__VLS_33, new __VLS_33({
delayDuration: (0),
}));
const __VLS_35 = __VLS_34({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_34));
__VLS_36.slots.default;
const __VLS_37 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_38 = __VLS_asFunctionalComponent(__VLS_37, new __VLS_37({
asChild: true,
}));
const __VLS_39 = __VLS_38({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_38));
__VLS_40.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(!__VLS_ctx.store.isCollapsed))
return;
__VLS_ctx.store.toggleViewMode();
} },
...{ class: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0" },
});
if (__VLS_ctx.store.viewMode === 'full') {
const __VLS_41 = {}.Columns;
/** @type {[typeof __VLS_components.Columns, ]} */ ;
// @ts-ignore
const __VLS_42 = __VLS_asFunctionalComponent(__VLS_41, new __VLS_41({
...{ class: "size-4" },
}));
const __VLS_43 = __VLS_42({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_42));
}
else {
const __VLS_45 = {}.Maximize;
/** @type {[typeof __VLS_components.Maximize, ]} */ ;
// @ts-ignore
const __VLS_46 = __VLS_asFunctionalComponent(__VLS_45, new __VLS_45({
...{ class: "size-4" },
}));
const __VLS_47 = __VLS_46({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_46));
}
var __VLS_40;
const __VLS_49 = {}.TooltipPortal;
/** @type {[typeof __VLS_components.TooltipPortal, typeof __VLS_components.TooltipPortal, ]} */ ;
// @ts-ignore
const __VLS_50 = __VLS_asFunctionalComponent(__VLS_49, new __VLS_49({}));
const __VLS_51 = __VLS_50({}, ...__VLS_functionalComponentArgsRest(__VLS_50));
__VLS_52.slots.default;
const __VLS_53 = {}.TooltipContent;
/** @type {[typeof __VLS_components.TooltipContent, typeof __VLS_components.TooltipContent, ]} */ ;
// @ts-ignore
const __VLS_54 = __VLS_asFunctionalComponent(__VLS_53, new __VLS_53({
side: "bottom",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}));
const __VLS_55 = __VLS_54({
side: "bottom",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}, ...__VLS_functionalComponentArgsRest(__VLS_54));
__VLS_56.slots.default;
(__VLS_ctx.store.viewMode === 'full' ? 'Split view' : 'Full view');
var __VLS_56;
var __VLS_52;
var __VLS_36;
const __VLS_57 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_58 = __VLS_asFunctionalComponent(__VLS_57, new __VLS_57({
delayDuration: (0),
}));
const __VLS_59 = __VLS_58({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_58));
__VLS_60.slots.default;
const __VLS_61 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_62 = __VLS_asFunctionalComponent(__VLS_61, new __VLS_61({
asChild: true,
}));
const __VLS_63 = __VLS_62({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_62));
__VLS_64.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(!__VLS_ctx.store.isCollapsed))
return;
__VLS_ctx.store.toggleTheme();
} },
...{ class: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0" },
});
if (__VLS_ctx.store.isDark) {
const __VLS_65 = {}.Sun;
/** @type {[typeof __VLS_components.Sun, ]} */ ;
// @ts-ignore
const __VLS_66 = __VLS_asFunctionalComponent(__VLS_65, new __VLS_65({
...{ class: "size-4" },
}));
const __VLS_67 = __VLS_66({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_66));
}
else {
const __VLS_69 = {}.Moon;
/** @type {[typeof __VLS_components.Moon, ]} */ ;
// @ts-ignore
const __VLS_70 = __VLS_asFunctionalComponent(__VLS_69, new __VLS_69({
...{ class: "size-4" },
}));
const __VLS_71 = __VLS_70({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_70));
}
var __VLS_64;
const __VLS_73 = {}.TooltipPortal;
/** @type {[typeof __VLS_components.TooltipPortal, typeof __VLS_components.TooltipPortal, ]} */ ;
// @ts-ignore
const __VLS_74 = __VLS_asFunctionalComponent(__VLS_73, new __VLS_73({}));
const __VLS_75 = __VLS_74({}, ...__VLS_functionalComponentArgsRest(__VLS_74));
__VLS_76.slots.default;
const __VLS_77 = {}.TooltipContent;
/** @type {[typeof __VLS_components.TooltipContent, typeof __VLS_components.TooltipContent, ]} */ ;
// @ts-ignore
const __VLS_78 = __VLS_asFunctionalComponent(__VLS_77, new __VLS_77({
side: "bottom",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}));
const __VLS_79 = __VLS_78({
side: "bottom",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}, ...__VLS_functionalComponentArgsRest(__VLS_78));
__VLS_80.slots.default;
(__VLS_ctx.store.isDark ? 'Light mode' : 'Dark mode');
var __VLS_80;
var __VLS_76;
var __VLS_60;
const __VLS_81 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_82 = __VLS_asFunctionalComponent(__VLS_81, new __VLS_81({
delayDuration: (0),
}));
const __VLS_83 = __VLS_82({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_82));
__VLS_84.slots.default;
const __VLS_85 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_86 = __VLS_asFunctionalComponent(__VLS_85, new __VLS_85({
asChild: true,
}));
const __VLS_87 = __VLS_86({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_86));
__VLS_88.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.logout) },
...{ class: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0" },
});
const __VLS_89 = {}.LogOut;
/** @type {[typeof __VLS_components.LogOut, ]} */ ;
// @ts-ignore
const __VLS_90 = __VLS_asFunctionalComponent(__VLS_89, new __VLS_89({
...{ class: "size-4" },
}));
const __VLS_91 = __VLS_90({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_90));
var __VLS_88;
const __VLS_93 = {}.TooltipPortal;
/** @type {[typeof __VLS_components.TooltipPortal, typeof __VLS_components.TooltipPortal, ]} */ ;
// @ts-ignore
const __VLS_94 = __VLS_asFunctionalComponent(__VLS_93, new __VLS_93({}));
const __VLS_95 = __VLS_94({}, ...__VLS_functionalComponentArgsRest(__VLS_94));
__VLS_96.slots.default;
const __VLS_97 = {}.TooltipContent;
/** @type {[typeof __VLS_components.TooltipContent, typeof __VLS_components.TooltipContent, ]} */ ;
// @ts-ignore
const __VLS_98 = __VLS_asFunctionalComponent(__VLS_97, new __VLS_97({
side: "bottom",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}));
const __VLS_99 = __VLS_98({
side: "bottom",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}, ...__VLS_functionalComponentArgsRest(__VLS_98));
__VLS_100.slots.default;
var __VLS_100;
var __VLS_96;
var __VLS_84;
}
else {
const __VLS_101 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_102 = __VLS_asFunctionalComponent(__VLS_101, new __VLS_101({
delayDuration: (0),
}));
const __VLS_103 = __VLS_102({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_102));
__VLS_104.slots.default;
const __VLS_105 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_106 = __VLS_asFunctionalComponent(__VLS_105, new __VLS_105({
asChild: true,
}));
const __VLS_107 = __VLS_106({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_106));
__VLS_108.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.compose) },
...{ class: "inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" },
});
const __VLS_109 = {}.PenSquare;
/** @type {[typeof __VLS_components.PenSquare, ]} */ ;
// @ts-ignore
const __VLS_110 = __VLS_asFunctionalComponent(__VLS_109, new __VLS_109({
...{ class: "size-4" },
}));
const __VLS_111 = __VLS_110({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_110));
var __VLS_108;
const __VLS_113 = {}.TooltipPortal;
/** @type {[typeof __VLS_components.TooltipPortal, typeof __VLS_components.TooltipPortal, ]} */ ;
// @ts-ignore
const __VLS_114 = __VLS_asFunctionalComponent(__VLS_113, new __VLS_113({}));
const __VLS_115 = __VLS_114({}, ...__VLS_functionalComponentArgsRest(__VLS_114));
__VLS_116.slots.default;
const __VLS_117 = {}.TooltipContent;
/** @type {[typeof __VLS_components.TooltipContent, typeof __VLS_components.TooltipContent, ]} */ ;
// @ts-ignore
const __VLS_118 = __VLS_asFunctionalComponent(__VLS_117, new __VLS_117({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}));
const __VLS_119 = __VLS_118({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}, ...__VLS_functionalComponentArgsRest(__VLS_118));
__VLS_120.slots.default;
var __VLS_120;
var __VLS_116;
var __VLS_104;
const __VLS_121 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_122 = __VLS_asFunctionalComponent(__VLS_121, new __VLS_121({
delayDuration: (0),
}));
const __VLS_123 = __VLS_122({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_122));
__VLS_124.slots.default;
const __VLS_125 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_126 = __VLS_asFunctionalComponent(__VLS_125, new __VLS_125({
asChild: true,
}));
const __VLS_127 = __VLS_126({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_126));
__VLS_128.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(!__VLS_ctx.store.isCollapsed))
return;
__VLS_ctx.store.toggleTheme();
} },
...{ class: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" },
});
if (__VLS_ctx.store.isDark) {
const __VLS_129 = {}.Sun;
/** @type {[typeof __VLS_components.Sun, ]} */ ;
// @ts-ignore
const __VLS_130 = __VLS_asFunctionalComponent(__VLS_129, new __VLS_129({
...{ class: "size-4" },
}));
const __VLS_131 = __VLS_130({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_130));
}
else {
const __VLS_133 = {}.Moon;
/** @type {[typeof __VLS_components.Moon, ]} */ ;
// @ts-ignore
const __VLS_134 = __VLS_asFunctionalComponent(__VLS_133, new __VLS_133({
...{ class: "size-4" },
}));
const __VLS_135 = __VLS_134({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_134));
}
var __VLS_128;
const __VLS_137 = {}.TooltipPortal;
/** @type {[typeof __VLS_components.TooltipPortal, typeof __VLS_components.TooltipPortal, ]} */ ;
// @ts-ignore
const __VLS_138 = __VLS_asFunctionalComponent(__VLS_137, new __VLS_137({}));
const __VLS_139 = __VLS_138({}, ...__VLS_functionalComponentArgsRest(__VLS_138));
__VLS_140.slots.default;
const __VLS_141 = {}.TooltipContent;
/** @type {[typeof __VLS_components.TooltipContent, typeof __VLS_components.TooltipContent, ]} */ ;
// @ts-ignore
const __VLS_142 = __VLS_asFunctionalComponent(__VLS_141, new __VLS_141({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}));
const __VLS_143 = __VLS_142({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}, ...__VLS_functionalComponentArgsRest(__VLS_142));
__VLS_144.slots.default;
(__VLS_ctx.store.isDark ? 'Light mode' : 'Dark mode');
var __VLS_144;
var __VLS_140;
var __VLS_124;
const __VLS_145 = {}.TooltipRoot;
/** @type {[typeof __VLS_components.TooltipRoot, typeof __VLS_components.TooltipRoot, ]} */ ;
// @ts-ignore
const __VLS_146 = __VLS_asFunctionalComponent(__VLS_145, new __VLS_145({
delayDuration: (0),
}));
const __VLS_147 = __VLS_146({
delayDuration: (0),
}, ...__VLS_functionalComponentArgsRest(__VLS_146));
__VLS_148.slots.default;
const __VLS_149 = {}.TooltipTrigger;
/** @type {[typeof __VLS_components.TooltipTrigger, typeof __VLS_components.TooltipTrigger, ]} */ ;
// @ts-ignore
const __VLS_150 = __VLS_asFunctionalComponent(__VLS_149, new __VLS_149({
asChild: true,
}));
const __VLS_151 = __VLS_150({
asChild: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_150));
__VLS_152.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.logout) },
...{ class: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" },
});
const __VLS_153 = {}.LogOut;
/** @type {[typeof __VLS_components.LogOut, ]} */ ;
// @ts-ignore
const __VLS_154 = __VLS_asFunctionalComponent(__VLS_153, new __VLS_153({
...{ class: "size-4" },
}));
const __VLS_155 = __VLS_154({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_154));
var __VLS_152;
const __VLS_157 = {}.TooltipPortal;
/** @type {[typeof __VLS_components.TooltipPortal, typeof __VLS_components.TooltipPortal, ]} */ ;
// @ts-ignore
const __VLS_158 = __VLS_asFunctionalComponent(__VLS_157, new __VLS_157({}));
const __VLS_159 = __VLS_158({}, ...__VLS_functionalComponentArgsRest(__VLS_158));
__VLS_160.slots.default;
const __VLS_161 = {}.TooltipContent;
/** @type {[typeof __VLS_components.TooltipContent, typeof __VLS_components.TooltipContent, ]} */ ;
// @ts-ignore
const __VLS_162 = __VLS_asFunctionalComponent(__VLS_161, new __VLS_161({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}));
const __VLS_163 = __VLS_162({
side: "right",
...{ class: "z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md" },
}, ...__VLS_functionalComponentArgsRest(__VLS_162));
__VLS_164.slots.default;
var __VLS_164;
var __VLS_160;
var __VLS_148;
/** @type {[typeof MailboxSelector, ]} */ ;
// @ts-ignore
const __VLS_165 = __VLS_asFunctionalComponent(MailboxSelector, new MailboxSelector({
isCollapsed: (true),
}));
const __VLS_166 = __VLS_165({
isCollapsed: (true),
}, ...__VLS_functionalComponentArgsRest(__VLS_165));
}
/** @type {[typeof FolderNav, ]} */ ;
// @ts-ignore
const __VLS_168 = __VLS_asFunctionalComponent(FolderNav, new FolderNav({
isCollapsed: (__VLS_ctx.store.isCollapsed),
}));
const __VLS_169 = __VLS_168({
isCollapsed: (__VLS_ctx.store.isCollapsed),
}, ...__VLS_functionalComponentArgsRest(__VLS_168));
var __VLS_12;
const __VLS_171 = {}.SplitterResizeHandle;
/** @type {[typeof __VLS_components.SplitterResizeHandle, ]} */ ;
// @ts-ignore
const __VLS_172 = __VLS_asFunctionalComponent(__VLS_171, new __VLS_171({
id: "sidebar-handle",
...{ class: "self-stretch w-[3px] bg-transparent hover:bg-border active:bg-primary/40 transition-colors" },
}));
const __VLS_173 = __VLS_172({
id: "sidebar-handle",
...{ class: "self-stretch w-[3px] bg-transparent hover:bg-border active:bg-primary/40 transition-colors" },
}, ...__VLS_functionalComponentArgsRest(__VLS_172));
if (__VLS_ctx.store.viewMode === 'split') {
const __VLS_175 = {}.SplitterPanel;
/** @type {[typeof __VLS_components.SplitterPanel, typeof __VLS_components.SplitterPanel, ]} */ ;
// @ts-ignore
const __VLS_176 = __VLS_asFunctionalComponent(__VLS_175, new __VLS_175({
id: "mail-list",
defaultSize: (35),
minSize: (25),
...{ class: "flex flex-col overflow-hidden" },
}));
const __VLS_177 = __VLS_176({
id: "mail-list",
defaultSize: (35),
minSize: (25),
...{ class: "flex flex-col overflow-hidden" },
}, ...__VLS_functionalComponentArgsRest(__VLS_176));
__VLS_178.slots.default;
/** @type {[typeof MessageList, ]} */ ;
// @ts-ignore
const __VLS_179 = __VLS_asFunctionalComponent(MessageList, new MessageList({}));
const __VLS_180 = __VLS_179({}, ...__VLS_functionalComponentArgsRest(__VLS_179));
var __VLS_178;
const __VLS_182 = {}.SplitterResizeHandle;
/** @type {[typeof __VLS_components.SplitterResizeHandle, ]} */ ;
// @ts-ignore
const __VLS_183 = __VLS_asFunctionalComponent(__VLS_182, new __VLS_182({
id: "display-handle",
...{ class: "self-stretch w-[3px] bg-transparent hover:bg-border active:bg-primary/40 transition-colors" },
}));
const __VLS_184 = __VLS_183({
id: "display-handle",
...{ class: "self-stretch w-[3px] bg-transparent hover:bg-border active:bg-primary/40 transition-colors" },
}, ...__VLS_functionalComponentArgsRest(__VLS_183));
const __VLS_186 = {}.SplitterPanel;
/** @type {[typeof __VLS_components.SplitterPanel, typeof __VLS_components.SplitterPanel, ]} */ ;
// @ts-ignore
const __VLS_187 = __VLS_asFunctionalComponent(__VLS_186, new __VLS_186({
id: "mail-display",
defaultSize: (45),
minSize: (30),
...{ class: "flex flex-col overflow-hidden" },
}));
const __VLS_188 = __VLS_187({
id: "mail-display",
defaultSize: (45),
minSize: (30),
...{ class: "flex flex-col overflow-hidden" },
}, ...__VLS_functionalComponentArgsRest(__VLS_187));
__VLS_189.slots.default;
if (__VLS_ctx.store.isComposeOpen && __VLS_ctx.store.isComposeFullView) {
/** @type {[typeof ComposeDialog, ]} */ ;
// @ts-ignore
const __VLS_190 = __VLS_asFunctionalComponent(ComposeDialog, new ComposeDialog({
panelMode: (true),
}));
const __VLS_191 = __VLS_190({
panelMode: (true),
}, ...__VLS_functionalComponentArgsRest(__VLS_190));
}
else {
/** @type {[typeof MessageDisplay, ]} */ ;
// @ts-ignore
const __VLS_193 = __VLS_asFunctionalComponent(MessageDisplay, new MessageDisplay({
message: (__VLS_ctx.store.currentMessage),
}));
const __VLS_194 = __VLS_193({
message: (__VLS_ctx.store.currentMessage),
}, ...__VLS_functionalComponentArgsRest(__VLS_193));
}
var __VLS_189;
}
else {
const __VLS_196 = {}.SplitterPanel;
/** @type {[typeof __VLS_components.SplitterPanel, typeof __VLS_components.SplitterPanel, ]} */ ;
// @ts-ignore
const __VLS_197 = __VLS_asFunctionalComponent(__VLS_196, new __VLS_196({
id: "mail-content",
defaultSize: (80),
minSize: (30),
...{ class: "flex flex-col overflow-hidden" },
}));
const __VLS_198 = __VLS_197({
id: "mail-content",
defaultSize: (80),
minSize: (30),
...{ class: "flex flex-col overflow-hidden" },
}, ...__VLS_functionalComponentArgsRest(__VLS_197));
__VLS_199.slots.default;
if (__VLS_ctx.store.isComposeOpen && __VLS_ctx.store.isComposeFullView) {
/** @type {[typeof ComposeDialog, ]} */ ;
// @ts-ignore
const __VLS_200 = __VLS_asFunctionalComponent(ComposeDialog, new ComposeDialog({
panelMode: (true),
}));
const __VLS_201 = __VLS_200({
panelMode: (true),
}, ...__VLS_functionalComponentArgsRest(__VLS_200));
}
else {
if (!__VLS_ctx.store.currentMessage) {
/** @type {[typeof MessageList, ]} */ ;
// @ts-ignore
const __VLS_203 = __VLS_asFunctionalComponent(MessageList, new MessageList({}));
const __VLS_204 = __VLS_203({}, ...__VLS_functionalComponentArgsRest(__VLS_203));
}
else {
/** @type {[typeof MessageDisplay, ]} */ ;
// @ts-ignore
const __VLS_206 = __VLS_asFunctionalComponent(MessageDisplay, new MessageDisplay({
message: (__VLS_ctx.store.currentMessage),
}));
const __VLS_207 = __VLS_206({
message: (__VLS_ctx.store.currentMessage),
}, ...__VLS_functionalComponentArgsRest(__VLS_206));
}
}
var __VLS_199;
}
var __VLS_8;
/** @type {[typeof ComposeDialog, ]} */ ;
// @ts-ignore
const __VLS_209 = __VLS_asFunctionalComponent(ComposeDialog, new ComposeDialog({}));
const __VLS_210 = __VLS_209({}, ...__VLS_functionalComponentArgsRest(__VLS_209));
var __VLS_3;
/** @type {__VLS_StyleScopedClasses['h-screen']} */ ;
/** @type {__VLS_StyleScopedClasses['w-screen']} */ ;
/** @type {__VLS_StyleScopedClasses['items-stretch']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
/** @type {__VLS_StyleScopedClasses['min-w-0']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2.5']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['text-primary-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-primary/90']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['h-8']} */ ;
/** @type {__VLS_StyleScopedClasses['w-8']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-accent-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['h-8']} */ ;
/** @type {__VLS_StyleScopedClasses['w-8']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-accent-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['h-8']} */ ;
/** @type {__VLS_StyleScopedClasses['w-8']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-accent-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['h-8']} */ ;
/** @type {__VLS_StyleScopedClasses['w-8']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['text-primary-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-primary/90']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['h-8']} */ ;
/** @type {__VLS_StyleScopedClasses['w-8']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['h-8']} */ ;
/** @type {__VLS_StyleScopedClasses['w-8']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['self-stretch']} */ ;
/** @type {__VLS_StyleScopedClasses['w-[3px]']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-transparent']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-border']} */ ;
/** @type {__VLS_StyleScopedClasses['active:bg-primary/40']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
/** @type {__VLS_StyleScopedClasses['overflow-hidden']} */ ;
/** @type {__VLS_StyleScopedClasses['self-stretch']} */ ;
/** @type {__VLS_StyleScopedClasses['w-[3px]']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-transparent']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-border']} */ ;
/** @type {__VLS_StyleScopedClasses['active:bg-primary/40']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
/** @type {__VLS_StyleScopedClasses['overflow-hidden']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-col']} */ ;
/** @type {__VLS_StyleScopedClasses['overflow-hidden']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
SplitterGroup: SplitterGroup,
SplitterPanel: SplitterPanel,
SplitterResizeHandle: SplitterResizeHandle,
TooltipProvider: TooltipProvider,
TooltipRoot: TooltipRoot,
TooltipTrigger: TooltipTrigger,
TooltipContent: TooltipContent,
TooltipPortal: TooltipPortal,
PenSquare: PenSquare,
Sun: Sun,
Moon: Moon,
LogOut: LogOut,
cn: cn,
MailboxSelector: MailboxSelector,
FolderNav: FolderNav,
MessageList: MessageList,
MessageDisplay: MessageDisplay,
ComposeDialog: ComposeDialog,
store: store,
logout: logout,
onCollapse: onCollapse,
onExpand: onExpand,
compose: compose,
};
},
});
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,76 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Mail, ChevronDown, Check } from 'lucide-vue-next'
import {
SelectRoot, SelectTrigger, SelectValue, SelectContent,
SelectItem, SelectItemText, SelectItemIndicator,
SelectPortal, SelectViewport,
} from 'radix-vue'
import { cn } from '../../lib/utils'
import { useMailStore } from '../../stores/mail'
defineProps({
isCollapsed: { type: Boolean, default: false },
})
const store = useMailStore()
const selected = computed({
get: () => store.currentMailbox,
set: (val) => { store.currentMailbox = val },
})
const currentDisplay = computed(() => {
const mb = store.mailboxes.find((m: any) => m.address === store.currentMailbox)
return mb?.display_name || store.currentMailbox
})
</script>
<template>
<div class="flex min-w-0">
<SelectRoot v-model="selected">
<SelectTrigger
:class="cn(
'flex items-center gap-2 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 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-2 [&>span]:truncate',
isCollapsed ? 'h-9 w-9 shrink-0 justify-center p-0 [&>span]:w-auto' : 'w-full',
)"
>
<SelectValue :placeholder="isCollapsed ? '' : 'Select account'">
<div class="flex items-center gap-2">
<Mail class="size-4 shrink-0" />
<span v-if="!isCollapsed" class="truncate">{{ currentDisplay }}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectPortal>
<SelectContent
class="z-50 min-w-[220px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
position="popper"
:side-offset="4"
>
<SelectViewport>
<SelectItem
v-for="mb in store.mailboxes"
:key="mb.address"
:value="mb.address"
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent focus:bg-accent"
>
<SelectItemIndicator class="absolute left-2">
<Check class="size-4" />
</SelectItemIndicator>
<SelectItemText class="pl-6">
<div class="flex items-center gap-2">
<Mail class="size-4 shrink-0 text-muted-foreground" />
<div>
<div>{{ mb.display_name || mb.address }}</div>
<div class="text-xs text-muted-foreground">{{ mb.address }}</div>
</div>
</div>
</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
</template>

View file

@ -0,0 +1,247 @@
/// <reference types="../../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { computed } from 'vue';
import { Mail, Check } from 'lucide-vue-next';
import { SelectRoot, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectItemText, SelectItemIndicator, SelectPortal, SelectViewport, } from 'radix-vue';
import { cn } from '../../lib/utils';
import { useMailStore } from '../../stores/mail';
const __VLS_props = defineProps({
isCollapsed: { type: Boolean, default: false },
});
const store = useMailStore();
const selected = computed({
get: () => store.currentMailbox,
set: (val) => { store.currentMailbox = val; },
});
const currentDisplay = computed(() => {
const mb = store.mailboxes.find((m) => m.address === store.currentMailbox);
return mb?.display_name || store.currentMailbox;
});
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;
let __VLS_directives;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex min-w-0" },
});
const __VLS_0 = {}.SelectRoot;
/** @type {[typeof __VLS_components.SelectRoot, typeof __VLS_components.SelectRoot, ]} */ ;
// @ts-ignore
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({
modelValue: (__VLS_ctx.selected),
}));
const __VLS_2 = __VLS_1({
modelValue: (__VLS_ctx.selected),
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
__VLS_3.slots.default;
const __VLS_4 = {}.SelectTrigger;
/** @type {[typeof __VLS_components.SelectTrigger, typeof __VLS_components.SelectTrigger, ]} */ ;
// @ts-ignore
const __VLS_5 = __VLS_asFunctionalComponent(__VLS_4, new __VLS_4({
...{ class: (__VLS_ctx.cn('flex items-center gap-2 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 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-2 [&>span]:truncate', __VLS_ctx.isCollapsed ? 'h-9 w-9 shrink-0 justify-center p-0 [&>span]:w-auto' : 'w-full')) },
}));
const __VLS_6 = __VLS_5({
...{ class: (__VLS_ctx.cn('flex items-center gap-2 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 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-2 [&>span]:truncate', __VLS_ctx.isCollapsed ? 'h-9 w-9 shrink-0 justify-center p-0 [&>span]:w-auto' : 'w-full')) },
}, ...__VLS_functionalComponentArgsRest(__VLS_5));
__VLS_7.slots.default;
const __VLS_8 = {}.SelectValue;
/** @type {[typeof __VLS_components.SelectValue, typeof __VLS_components.SelectValue, ]} */ ;
// @ts-ignore
const __VLS_9 = __VLS_asFunctionalComponent(__VLS_8, new __VLS_8({
placeholder: (__VLS_ctx.isCollapsed ? '' : 'Select account'),
}));
const __VLS_10 = __VLS_9({
placeholder: (__VLS_ctx.isCollapsed ? '' : 'Select account'),
}, ...__VLS_functionalComponentArgsRest(__VLS_9));
__VLS_11.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex items-center gap-2" },
});
const __VLS_12 = {}.Mail;
/** @type {[typeof __VLS_components.Mail, ]} */ ;
// @ts-ignore
const __VLS_13 = __VLS_asFunctionalComponent(__VLS_12, new __VLS_12({
...{ class: "size-4 shrink-0" },
}));
const __VLS_14 = __VLS_13({
...{ class: "size-4 shrink-0" },
}, ...__VLS_functionalComponentArgsRest(__VLS_13));
if (!__VLS_ctx.isCollapsed) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "truncate" },
});
(__VLS_ctx.currentDisplay);
}
var __VLS_11;
var __VLS_7;
const __VLS_16 = {}.SelectPortal;
/** @type {[typeof __VLS_components.SelectPortal, typeof __VLS_components.SelectPortal, ]} */ ;
// @ts-ignore
const __VLS_17 = __VLS_asFunctionalComponent(__VLS_16, new __VLS_16({}));
const __VLS_18 = __VLS_17({}, ...__VLS_functionalComponentArgsRest(__VLS_17));
__VLS_19.slots.default;
const __VLS_20 = {}.SelectContent;
/** @type {[typeof __VLS_components.SelectContent, typeof __VLS_components.SelectContent, ]} */ ;
// @ts-ignore
const __VLS_21 = __VLS_asFunctionalComponent(__VLS_20, new __VLS_20({
...{ class: "z-50 min-w-[220px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md" },
position: "popper",
sideOffset: (4),
}));
const __VLS_22 = __VLS_21({
...{ class: "z-50 min-w-[220px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md" },
position: "popper",
sideOffset: (4),
}, ...__VLS_functionalComponentArgsRest(__VLS_21));
__VLS_23.slots.default;
const __VLS_24 = {}.SelectViewport;
/** @type {[typeof __VLS_components.SelectViewport, typeof __VLS_components.SelectViewport, ]} */ ;
// @ts-ignore
const __VLS_25 = __VLS_asFunctionalComponent(__VLS_24, new __VLS_24({}));
const __VLS_26 = __VLS_25({}, ...__VLS_functionalComponentArgsRest(__VLS_25));
__VLS_27.slots.default;
for (const [mb] of __VLS_getVForSourceType((__VLS_ctx.store.mailboxes))) {
const __VLS_28 = {}.SelectItem;
/** @type {[typeof __VLS_components.SelectItem, typeof __VLS_components.SelectItem, ]} */ ;
// @ts-ignore
const __VLS_29 = __VLS_asFunctionalComponent(__VLS_28, new __VLS_28({
key: (mb.address),
value: (mb.address),
...{ class: "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent focus:bg-accent" },
}));
const __VLS_30 = __VLS_29({
key: (mb.address),
value: (mb.address),
...{ class: "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent focus:bg-accent" },
}, ...__VLS_functionalComponentArgsRest(__VLS_29));
__VLS_31.slots.default;
const __VLS_32 = {}.SelectItemIndicator;
/** @type {[typeof __VLS_components.SelectItemIndicator, typeof __VLS_components.SelectItemIndicator, ]} */ ;
// @ts-ignore
const __VLS_33 = __VLS_asFunctionalComponent(__VLS_32, new __VLS_32({
...{ class: "absolute left-2" },
}));
const __VLS_34 = __VLS_33({
...{ class: "absolute left-2" },
}, ...__VLS_functionalComponentArgsRest(__VLS_33));
__VLS_35.slots.default;
const __VLS_36 = {}.Check;
/** @type {[typeof __VLS_components.Check, ]} */ ;
// @ts-ignore
const __VLS_37 = __VLS_asFunctionalComponent(__VLS_36, new __VLS_36({
...{ class: "size-4" },
}));
const __VLS_38 = __VLS_37({
...{ class: "size-4" },
}, ...__VLS_functionalComponentArgsRest(__VLS_37));
var __VLS_35;
const __VLS_40 = {}.SelectItemText;
/** @type {[typeof __VLS_components.SelectItemText, typeof __VLS_components.SelectItemText, ]} */ ;
// @ts-ignore
const __VLS_41 = __VLS_asFunctionalComponent(__VLS_40, new __VLS_40({
...{ class: "pl-6" },
}));
const __VLS_42 = __VLS_41({
...{ class: "pl-6" },
}, ...__VLS_functionalComponentArgsRest(__VLS_41));
__VLS_43.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex items-center gap-2" },
});
const __VLS_44 = {}.Mail;
/** @type {[typeof __VLS_components.Mail, ]} */ ;
// @ts-ignore
const __VLS_45 = __VLS_asFunctionalComponent(__VLS_44, new __VLS_44({
...{ class: "size-4 shrink-0 text-muted-foreground" },
}));
const __VLS_46 = __VLS_45({
...{ class: "size-4 shrink-0 text-muted-foreground" },
}, ...__VLS_functionalComponentArgsRest(__VLS_45));
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
(mb.display_name || mb.address);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "text-xs text-muted-foreground" },
});
(mb.address);
var __VLS_43;
var __VLS_31;
}
var __VLS_27;
var __VLS_23;
var __VLS_19;
var __VLS_3;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['min-w-0']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['truncate']} */ ;
/** @type {__VLS_StyleScopedClasses['z-50']} */ ;
/** @type {__VLS_StyleScopedClasses['min-w-[220px]']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-popover']} */ ;
/** @type {__VLS_StyleScopedClasses['p-1']} */ ;
/** @type {__VLS_StyleScopedClasses['text-popover-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-md']} */ ;
/** @type {__VLS_StyleScopedClasses['relative']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['cursor-pointer']} */ ;
/** @type {__VLS_StyleScopedClasses['select-none']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:bg-accent']} */ ;
/** @type {__VLS_StyleScopedClasses['absolute']} */ ;
/** @type {__VLS_StyleScopedClasses['left-2']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['pl-6']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
/** @type {__VLS_StyleScopedClasses['size-4']} */ ;
/** @type {__VLS_StyleScopedClasses['shrink-0']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
Mail: Mail,
Check: Check,
SelectRoot: SelectRoot,
SelectTrigger: SelectTrigger,
SelectValue: SelectValue,
SelectContent: SelectContent,
SelectItem: SelectItem,
SelectItemText: SelectItemText,
SelectItemIndicator: SelectItemIndicator,
SelectPortal: SelectPortal,
SelectViewport: SelectViewport,
cn: cn,
store: store,
selected: selected,
currentDisplay: currentDisplay,
};
},
props: {
isCollapsed: { type: Boolean, default: false },
},
});
export default (await import('vue')).defineComponent({
setup() {
return {};
},
props: {
isCollapsed: { type: Boolean, default: false },
},
});
; /* PartiallyEnd: #4569/main.vue */
//# sourceMappingURL=MailboxSelector.vue.js.map

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more