Merge pull request #341 from ChrispyBacon-dev/dockflare-mail
Dockflare mail - v3.0.1 unstable-RC
217
.github/workflows/docker-image.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
34
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
47
README.MD
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
353
dockflare/app/core/email_manager.py
Normal 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)
|
||||
179
dockflare/app/core/worker_templates/inbound_worker.js
Normal 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}`);
|
||||
}
|
||||
};
|
||||
90
dockflare/app/core/worker_templates/outbound_worker.js
Normal 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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
513
dockflare/app/templates/email.html
Normal 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 }} · {{ 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
1186
dockflare/app/web/email_routes.py
Normal 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>')
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"]
|
||||
19
mail-manager/app/__init__.py
Normal 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
|
||||
0
mail-manager/app/api/__init__.py
Normal file
28
mail-manager/app/api/middleware.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from functools import wraps
|
||||
from flask import request, jsonify
|
||||
from app.core.jwt_auth import verify_jwt
|
||||
|
||||
def jwt_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid token'}), 401
|
||||
|
||||
token = auth_header.split(' ')[1]
|
||||
decoded = verify_jwt(token)
|
||||
if not decoded:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
|
||||
request.user = decoded
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
@jwt_required
|
||||
def decorated(*args, **kwargs):
|
||||
if request.user.get('role') != 'admin':
|
||||
return jsonify({'error': 'Admin required'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
1235
mail-manager/app/api/routes.py
Normal file
221
mail-manager/app/api/system.py
Normal 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
|
||||
421
mail-manager/app/api/webhook.py
Normal 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
|
||||
71
mail-manager/app/config.py
Normal 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
|
||||
0
mail-manager/app/core/__init__.py
Normal file
11
mail-manager/app/core/bounce_handler.py
Normal 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()
|
||||
263
mail-manager/app/core/database.py
Normal 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)
|
||||
17
mail-manager/app/core/jwt_auth.py
Normal 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
|
||||
101
mail-manager/app/core/mime_parser.py
Normal 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
|
||||
136
mail-manager/app/core/push.py
Normal 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)
|
||||
51
mail-manager/app/core/r2_client.py
Normal 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)
|
||||
34
mail-manager/app/core/rate_limiter.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import time
|
||||
import threading
|
||||
|
||||
class OutboundRateLimiter:
|
||||
def __init__(self, hourly_limit=50, daily_limit=200):
|
||||
self.hourly_limit = hourly_limit
|
||||
self.daily_limit = daily_limit
|
||||
self.history = {}
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def check_rate(self, from_address):
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
if from_address not in self.history:
|
||||
self.history[from_address] = []
|
||||
|
||||
self.history[from_address] = [t for t in self.history[from_address] if now - t < 86400]
|
||||
|
||||
recent_hour = [t for t in self.history[from_address] if now - t < 3600]
|
||||
|
||||
if len(self.history[from_address]) >= self.daily_limit:
|
||||
return False, "Daily limit reached"
|
||||
if len(recent_hour) >= self.hourly_limit:
|
||||
return False, "Hourly limit reached"
|
||||
|
||||
return True, ""
|
||||
|
||||
def record_send(self, from_address):
|
||||
with self.lock:
|
||||
if from_address not in self.history:
|
||||
self.history[from_address] = []
|
||||
self.history[from_address].append(time.time())
|
||||
|
||||
limiter = OutboundRateLimiter()
|
||||
63
mail-manager/app/core/scheduler.py
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
10
mail-manager/requirements.txt
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
58
webmail/docker-entrypoint.sh
Normal 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
|
|
@ -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
|
|
@ -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
52
webmail/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
webmail/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
webmail/public/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
webmail/public/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
webmail/public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
webmail/public/favicon/apple-touch-icon.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
webmail/public/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 911 B |
BIN
webmail/public/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
webmail/public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
36
webmail/public/offline.html
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
1
webmail/src/App.vue.js.map
Normal 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
|
|
@ -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
|
||||
1
webmail/src/api/auth.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;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
|
|
@ -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
|
|
@ -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
|
||||
1
webmail/src/api/client.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"client.js","sourceRoot":"","sources":["client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,MAAM,MAAM,WAAW,CAAA;AAE9B,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,CAAC;IAC7B,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,KAAK;IACd,OAAO,EAAE;QACP,cAAc,EAAE,kBAAkB;KACnC;CACF,CAAC,CAAA;AAEF,SAAS,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE;IAC1C,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,CAAC,OAAO,CAAC,aAAa,GAAG,UAAU,KAAK,EAAE,CAAA;IAClD,CAAC;IACD,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
|
|
@ -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
|
|
@ -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
|
||||
1
webmail/src/api/mail.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"mail.js","sourceRoot":"","sources":["mail.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,YAAY,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,YAAY,CAAC;IAC/C,UAAU,EAAE,CAAC,OAAe,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,cAAc,OAAO,UAAU,CAAC;IAC/E,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
|
|
@ -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`),
|
||||
}
|
||||
82
webmail/src/assets/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
60
webmail/src/components/mail/AttachmentBar.vue
Normal 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>
|
||||
157
webmail/src/components/mail/AttachmentBar.vue.js
Normal 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
|
||||
1
webmail/src/components/mail/AttachmentBar.vue.js.map
Normal file
441
webmail/src/components/mail/ComposeDialog.vue
Normal 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>
|
||||
1212
webmail/src/components/mail/ComposeDialog.vue.js
Normal file
1
webmail/src/components/mail/ComposeDialog.vue.js.map
Normal file
39
webmail/src/components/mail/ComposeEditor.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const store = useMailStore()
|
||||
|
||||
const text = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
text.value = val
|
||||
store.composeBody = val
|
||||
})
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const val = (e.target as HTMLTextAreaElement).value
|
||||
text.value = val
|
||||
store.composeBody = val
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
const getHTML = () => text.value
|
||||
defineExpose({ getHTML })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col border rounded-md overflow-hidden">
|
||||
<textarea
|
||||
:value="text"
|
||||
@input="onInput"
|
||||
placeholder="Write your message..."
|
||||
class="flex-1 p-4 text-sm resize-none focus:outline-none min-h-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
72
webmail/src/components/mail/ComposeEditor.vue.js
Normal 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
|
||||
1
webmail/src/components/mail/ComposeEditor.vue.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"ComposeEditor.vue.js","sourceRoot":"","sources":["ComposeEditor.vue"],"names":[],"mappings":"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"}
|
||||
286
webmail/src/components/mail/FolderNav.vue
Normal 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>
|
||||
608
webmail/src/components/mail/FolderNav.vue.js
Normal 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
|
||||
1
webmail/src/components/mail/FolderNav.vue.js.map
Normal file
248
webmail/src/components/mail/MailLayout.vue
Normal 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>
|
||||
873
webmail/src/components/mail/MailLayout.vue.js
Normal 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
|
||||
1
webmail/src/components/mail/MailLayout.vue.js.map
Normal file
76
webmail/src/components/mail/MailboxSelector.vue
Normal 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>
|
||||
247
webmail/src/components/mail/MailboxSelector.vue.js
Normal 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
|
||||