PWA feature , web mail fixes, WIP push notifications

This commit is contained in:
ChrispyBacon-dev 2026-04-12 14:15:27 +02:00
parent 404710bee0
commit 3cedba5ac7
48 changed files with 5745 additions and 50 deletions

View file

@ -1,3 +1,4 @@
import base64
import hmac
import ipaddress
import json
@ -9,7 +10,7 @@ import jwt
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, current_app
from flask_login import login_required, current_user
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
from werkzeug.security import generate_password_hash, check_password_hash
from app import config, docker_client, limiter
from app.core import email_manager
@ -106,7 +107,21 @@ def setup_email_domain():
)
email_cfg['jwt_signing_key'] = private_bytes.decode('utf-8')
email_cfg['jwt_public_key'] = public_bytes.decode('utf-8')
if 'vapid_private_key' not in email_cfg:
vapid_key = ec.generate_private_key(ec.SECP256R1())
email_cfg['vapid_private_key'] = vapid_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode()
email_cfg['vapid_public_key'] = base64.urlsafe_b64encode(
vapid_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
).rstrip(b'=').decode()
webhook_secret = secrets.token_hex(32)
outbound_auth_secret = secrets.token_hex(32)
inbound_worker_name = f"dockflare-mail-inbound-{zone_name.replace('.', '-')}"
@ -470,6 +485,23 @@ def internal_mail_config():
cfg = config.EMAIL_CONFIG
if not cfg or not cfg.get('enabled') or not cfg.get('domains'):
return jsonify({'configured': False})
if 'vapid_private_key' not in cfg or not cfg.get('vapid_private_key'):
vapid_key = ec.generate_private_key(ec.SECP256R1())
cfg['vapid_private_key'] = vapid_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode()
cfg['vapid_public_key'] = base64.urlsafe_b64encode(
vapid_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
).rstrip(b'=').decode()
save_email_config(cfg)
logging.info("Generated VAPID keys for existing email config")
domains_out = {}
for zone_name, d in cfg['domains'].items():
domains_out[zone_name] = {
@ -491,6 +523,8 @@ def internal_mail_config():
'jwt_algorithm': config.EMAIL_JWT_ALGORITHM,
'jwt_issuer': config.EMAIL_JWT_ISSUER,
'jwt_audience': config.EMAIL_JWT_AUDIENCE,
'vapid_private_key': cfg.get('vapid_private_key', ''),
'vapid_public_key': cfg.get('vapid_public_key', ''),
'domains': domains_out
})

View file

@ -58,6 +58,91 @@ def stats():
})
@api_bp.route('/notifications/vapid-key', methods=['GET'])
@jwt_required
def get_vapid_key():
public_key = os.environ.get('VAPID_PUBLIC_KEY', '')
if not public_key:
return jsonify({"error": "push notifications not configured"}), 503
return jsonify({"public_key": public_key})
@api_bp.route('/notifications/subscribe', methods=['POST'])
@jwt_required
def push_subscribe():
data = request.json or {}
endpoint = data.get('endpoint', '')
keys = data.get('keys') or {}
p256dh = keys.get('p256dh', '')
auth_key = keys.get('auth', '')
mailbox_address = data.get('mailbox_address', '')
if not endpoint or not p256dh or not auth_key or not mailbox_address:
return jsonify({"error": "endpoint, keys, and mailbox_address are required"}), 400
if not _check_mailbox_access(mailbox_address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
now = datetime.now(timezone.utc).isoformat()
db.execute("""
INSERT INTO push_subscriptions (mailbox_address, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
mailbox_address=excluded.mailbox_address,
p256dh=excluded.p256dh,
auth=excluded.auth,
created_at=excluded.created_at
""", (mailbox_address, endpoint, p256dh, auth_key, now))
db.commit()
return jsonify({"status": "subscribed"})
@api_bp.route('/notifications/subscribe', methods=['DELETE'])
@jwt_required
def push_unsubscribe():
data = request.json or {}
endpoint = data.get('endpoint', '')
if not endpoint:
return jsonify({"error": "endpoint is required"}), 400
db = get_db()
db.execute("DELETE FROM push_subscriptions WHERE endpoint=?", (endpoint,))
db.commit()
return jsonify({"status": "unsubscribed"})
@api_bp.route('/mailboxes/status', methods=['GET'])
@jwt_required
def mailbox_status():
db = get_db()
user = request.user
if user.get('role') == 'admin':
cur = db.execute("SELECT address FROM mailboxes WHERE is_active=1")
addresses = [row['address'] for row in cur.fetchall()]
else:
addresses = user.get('mailboxes', [])
results = []
for address in addresses:
cur = db.execute("""
SELECT
COUNT(CASE WHEN m.is_read=0 THEN 1 END) AS unread_count,
MAX(m.received_at) AS latest_received_at
FROM messages m
JOIN folders f ON m.folder_id = f.id
WHERE m.mailbox_address=? AND f.name='Inbox' AND m.is_draft=0
""", (address,))
row = cur.fetchone()
results.append({
'address': address,
'unread_count': row['unread_count'] or 0,
'latest_received_at': row['latest_received_at'],
})
return jsonify(results)
@api_bp.route('/mailboxes', methods=['GET'])
@admin_required
def get_mailboxes():
@ -130,6 +215,36 @@ def patch_mailbox(address):
return jsonify({"status": "updated"})
@api_bp.route('/mailboxes/<address>/preferences', methods=['GET'])
@jwt_required
def get_mailbox_preferences(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute("SELECT notification_preview FROM mailboxes WHERE address=?", (address,))
row = cur.fetchone()
if not row:
return jsonify({"error": "not found"}), 404
preview = row['notification_preview'] if row['notification_preview'] is not None else 1
return jsonify({"notification_preview": bool(preview)})
@api_bp.route('/mailboxes/<address>/preferences', methods=['PATCH'])
@jwt_required
def patch_mailbox_preferences(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
db = get_db()
if 'notification_preview' in data:
db.execute(
"UPDATE mailboxes SET notification_preview=? WHERE address=?",
(int(bool(data['notification_preview'])), address),
)
db.commit()
return jsonify({"status": "updated"})
@api_bp.route('/mailboxes/<address>/messages', methods=['GET'])
@jwt_required
def get_messages(address):

View file

@ -8,6 +8,7 @@ from datetime import datetime, timezone
from flask import Blueprint, request, jsonify
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
@ -135,6 +136,12 @@ def inbound():
))
db.commit()
send_push_notifications(to_address, {
'message_id': msg_id,
'subject': parsed['subject'],
'from_name': parsed['from_name'] or parsed['from_address'],
'mailbox': to_address,
})
delete_from_r2(r2_key, domain_cfg)
log.info("Inbound delivered: message=%s to=%s db_id=%s",

View file

@ -101,6 +101,16 @@ _SCHEMA = """
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 UNIQUE,
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
);
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
@ -144,9 +154,10 @@ def get_standalone_db():
return _connect()
def _migrate(conn):
def _migrate(conn):
migrations = [
"ALTER TABLE folders ADD COLUMN color TEXT",
"ALTER TABLE mailboxes ADD COLUMN notification_preview INTEGER DEFAULT 1",
]
for sql in migrations:
try:

View file

@ -0,0 +1,75 @@
import json
import logging
import os
import threading
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 _dispatch(mailbox_address: str, payload: dict):
private_key = os.environ.get('VAPID_PRIVATE_KEY', '')
if not private_key:
return
from pywebpush import webpush, WebPushException
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', '')
cur = db.execute(
"SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE mailbox_address=?",
(mailbox_address,),
)
subscriptions = list(cur.fetchall())
for sub in subscriptions:
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"},
)
except WebPushException as e:
if e.response is not None and e.response.status_code == 410:
db.execute("DELETE FROM push_subscriptions WHERE id=?", (sub['id'],))
db.commit()
log.info("Removed stale push subscription for %s", mailbox_address)
else:
log.warning("Push failed for %s: %s", mailbox_address, e)
except Exception:
log.exception("Push dispatch error for %s", mailbox_address)
finally:
db.close()

View file

@ -36,6 +36,8 @@ def bootstrap():
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")

View file

@ -7,3 +7,4 @@ cryptography==46.0.6
python-dateutil==2.9.0.post0
nh3==0.2.21
requests==2.33.1
pywebpush==2.0.0

View file

@ -8,7 +8,7 @@ server {
listen 80;
server_name _;
client_max_body_size 25m;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';";
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; connect-src 'self';";
location /api/ {
proxy_pass http://dockflare-mail-manager:8025/api/;
@ -24,6 +24,18 @@ server {
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";

View file

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DockFlare Webmail</title>
<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">

4434
webmail/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,9 @@
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
"vite-plugin-pwa": "^0.21.0",
"vue-tsc": "^2.1.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0"
}
}

View file

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

View file

@ -1,7 +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 />
</template>
<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>

View file

@ -1,5 +1,7 @@
/// <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;
@ -9,13 +11,59 @@ const __VLS_0 = {}.RouterView;
// @ts-ignore
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({}));
const __VLS_2 = __VLS_1({}, ...__VLS_functionalComponentArgsRest(__VLS_1));
var __VLS_4 = {};
var __VLS_3;
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,
};
},
});

View file

@ -1 +1 @@
{"version":3,"file":"App.vue.js","sourceRoot":"","sources":["App.vue"],"names":[],"mappings":"AAMW,8EAA8E;AAEzF,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACvC,QAAQ,CAAA,CAAA,yCAAyC;AAIjD,MAAM,SAAS,GAAG,EAAqE,CAAC;AAExF,IAAI,gBAAiE,CAAC;AAEtE,IAAI,gBAAiE,CAAC;AACtE,MAAM,OAAO,GAAI,EAA+G,CAAC,UAAU,CAAC;AAC5I,qDAAqD,CAAA,CAAC;AACtD,aAAa;AACb,MAAM,OAAO,GAAG,2BAA2B,CAAC,OAAO,EAAE,IAAI,OAAO,CAAC,EAChE,CAAC,CAAC,CAAC;AACJ,MAAM,OAAO,GAAG,OAAO,CAAC,EACvB,EAAE,GAAG,iCAAiC,CAAC,OAAO,CAAC,CAAC,CAAC;AAClD,IAAI,OAAO,GAAG,EAAmE,CAAC;AAClF,IAAI,OAA0E,CAAC;AAO/E,IAAI,aAK+D,CAAC;AACpE,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACzD,KAAK;QACL,OAAO;YACP,UAAU,EAAE,UAA+B;SAC1C,CAAC;IACF,CAAC;CACA,CAAC,CAAC;AACH,eAAe,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC;IACrD,KAAK;QACL,OAAO,EACN,CAAC;IACF,CAAC;CACA,CAAC,CAAC;AACH,CAAC,CAAA,kCAAkC"}
{"version":3,"file":"App.vue.js","sourceRoot":"","sources":["App.vue"],"names":[],"mappings":"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"}

View file

@ -1,9 +1,8 @@
import apiClient from './client';
export const authApi = {
checkAuth: () => apiClient.get('/auth/me'),
loginWithPassword: async (baseUrl, email, password) => {
const url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const response = await fetch(`${url}/email/auth/login`, {
loginWithPassword: async (email, password) => {
const response = await fetch('/email/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),

View file

@ -1 +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,OAAe,EAAE,KAAa,EAAE,QAAgB,EAAE,EAAE;QAC5E,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;QAClE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,GAAG,mBAAmB,EAAE;YACtD,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"}
{"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"}

View file

@ -14,6 +14,7 @@ export const mailApi = {
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),

View file

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

View file

@ -20,6 +20,10 @@ export const mailApi = {
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),

View file

@ -0,0 +1,26 @@
import { ref } from 'vue';
const deferredPrompt = ref(null);
const canInstall = ref(false);
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt.value = e;
canInstall.value = true;
});
window.addEventListener('appinstalled', () => {
deferredPrompt.value = null;
canInstall.value = false;
});
export function useInstallPrompt() {
const promptInstall = async () => {
if (!deferredPrompt.value)
return;
await deferredPrompt.value.prompt();
const { outcome } = await deferredPrompt.value.userChoice;
if (outcome === 'accepted') {
deferredPrompt.value = null;
canInstall.value = false;
}
};
return { canInstall, promptInstall };
}
//# sourceMappingURL=useInstallPrompt.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"useInstallPrompt.js","sourceRoot":"","sources":["useInstallPrompt.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAA;AAOzB,MAAM,cAAc,GAAG,GAAG,CAAkC,IAAI,CAAC,CAAA;AACjE,MAAM,UAAU,GAAG,GAAG,CAAC,KAAK,CAAC,CAAA;AAE7B,MAAM,CAAC,gBAAgB,CAAC,qBAAqB,EAAE,CAAC,CAAC,EAAE,EAAE;IACnD,CAAC,CAAC,cAAc,EAAE,CAAA;IAClB,cAAc,CAAC,KAAK,GAAG,CAA6B,CAAA;IACpD,UAAU,CAAC,KAAK,GAAG,IAAI,CAAA;AACzB,CAAC,CAAC,CAAA;AAEF,MAAM,CAAC,gBAAgB,CAAC,cAAc,EAAE,GAAG,EAAE;IAC3C,cAAc,CAAC,KAAK,GAAG,IAAI,CAAA;IAC3B,UAAU,CAAC,KAAK,GAAG,KAAK,CAAA;AAC1B,CAAC,CAAC,CAAA;AAEF,MAAM,UAAU,gBAAgB;IAC9B,MAAM,aAAa,GAAG,KAAK,IAAI,EAAE;QAC/B,IAAI,CAAC,cAAc,CAAC,KAAK;YAAE,OAAM;QACjC,MAAM,cAAc,CAAC,KAAK,CAAC,MAAM,EAAE,CAAA;QACnC,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,cAAc,CAAC,KAAK,CAAC,UAAU,CAAA;QACzD,IAAI,OAAO,KAAK,UAAU,EAAE,CAAC;YAC3B,cAAc,CAAC,KAAK,GAAG,IAAI,CAAA;YAC3B,UAAU,CAAC,KAAK,GAAG,KAAK,CAAA;QAC1B,CAAC;IACH,CAAC,CAAA;IAED,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,CAAA;AACtC,CAAC"}

View file

@ -0,0 +1,34 @@
import { ref } from 'vue'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const deferredPrompt = ref<BeforeInstallPromptEvent | null>(null)
const canInstall = ref(false)
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt.value = e as BeforeInstallPromptEvent
canInstall.value = true
})
window.addEventListener('appinstalled', () => {
deferredPrompt.value = null
canInstall.value = false
})
export function useInstallPrompt() {
const promptInstall = async () => {
if (!deferredPrompt.value) return
await deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
deferredPrompt.value = null
canInstall.value = false
}
}
return { canInstall, promptInstall }
}

View file

@ -1,17 +1,26 @@
import { ref } from 'vue';
import { mailApi } from '../api/mail';
import { useMailStore } from '../stores/mail';
import { useAuthStore } from '../stores/auth';
export function useMail() {
const store = useMailStore();
const authStore = useAuthStore();
const loading = ref(false);
const error = ref('');
const loadMailboxes = async () => {
loading.value = true;
try {
const res = await mailApi.getMailboxes();
store.mailboxes = res.data;
if (res.data.length > 0 && !store.currentMailbox) {
store.currentMailbox = res.data[0].address;
const decoded = authStore.decodeToken();
if (decoded?.role === 'user') {
const addresses = decoded.mailboxes || [];
store.mailboxes = addresses.map((addr) => ({ address: addr, display_name: addr }));
}
else {
const res = await mailApi.getMailboxes();
store.mailboxes = res.data;
}
if (store.mailboxes.length > 0 && !store.currentMailbox) {
store.currentMailbox = store.mailboxes[0].address;
}
}
catch (e) {

View file

@ -1 +1 @@
{"version":3,"file":"useMail.js","sourceRoot":"","sources":["useMail.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAA;AACzB,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AACrC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,MAAM,UAAU,OAAO;IACrB,MAAM,KAAK,GAAG,YAAY,EAAE,CAAA;IAC5B,MAAM,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,CAAA;IAC1B,MAAM,KAAK,GAAG,GAAG,CAAC,EAAE,CAAC,CAAA;IAErB,MAAM,aAAa,GAAG,KAAK,IAAI,EAAE;QAC/B,OAAO,CAAC,KAAK,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,YAAY,EAAE,CAAA;YACxC,KAAK,CAAC,SAAS,GAAG,GAAG,CAAC,IAAI,CAAA;YAC1B,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;gBACjD,KAAK,CAAC,cAAc,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;YAC5C,CAAC;QACH,CAAC;QAAC,OAAO,CAAM,EAAE,CAAC;YAChB,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,OAAO,CAAA;QACzB,CAAC;gBAAS,CAAC;YACT,OAAO,CAAC,KAAK,GAAG,KAAK,CAAA;QACvB,CAAC;IACH,CAAC,CAAA;IAED,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,aAAa,EAAE,CAAA;AACjD,CAAC"}
{"version":3,"file":"useMail.js","sourceRoot":"","sources":["useMail.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAA;AACzB,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AACrC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,MAAM,UAAU,OAAO;IACrB,MAAM,KAAK,GAAG,YAAY,EAAE,CAAA;IAC5B,MAAM,SAAS,GAAG,YAAY,EAAE,CAAA;IAChC,MAAM,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,CAAA;IAC1B,MAAM,KAAK,GAAG,GAAG,CAAC,EAAE,CAAC,CAAA;IAErB,MAAM,aAAa,GAAG,KAAK,IAAI,EAAE;QAC/B,OAAO,CAAC,KAAK,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,SAAS,CAAC,WAAW,EAAE,CAAA;YACvC,IAAI,OAAO,EAAE,IAAI,KAAK,MAAM,EAAE,CAAC;gBAC7B,MAAM,SAAS,GAAa,OAAO,CAAC,SAAS,IAAI,EAAE,CAAA;gBACnD,KAAK,CAAC,SAAS,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;YAC5F,CAAC;iBAAM,CAAC;gBACN,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,YAAY,EAAE,CAAA;gBACxC,KAAK,CAAC,SAAS,GAAG,GAAG,CAAC,IAAI,CAAA;YAC5B,CAAC;YACD,IAAI,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;gBACxD,KAAK,CAAC,cAAc,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;YACnD,CAAC;QACH,CAAC;QAAC,OAAO,CAAM,EAAE,CAAC;YAChB,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,OAAO,CAAA;QACzB,CAAC;gBAAS,CAAC;YACT,OAAO,CAAC,KAAK,GAAG,KAAK,CAAA;QACvB,CAAC;IACH,CAAC,CAAA;IAED,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,aAAa,EAAE,CAAA;AACjD,CAAC"}

View file

@ -0,0 +1,58 @@
import { onUnmounted, ref, watch } from 'vue';
import { mailApi } from '@/api/mail';
import { useNotificationsStore } from '@/stores/notifications';
import { useMailStore } from '@/stores/mail';
export function useMailPolling() {
const notificationsStore = useNotificationsStore();
const mailStore = useMailStore();
const lastSeen = ref({});
const initialized = ref(false);
const poll = async () => {
if (mailStore.mailboxes.length === 0)
return;
try {
const res = await mailApi.getMailboxStatus();
const statuses = res.data;
if (!initialized.value) {
for (const s of statuses) {
lastSeen.value[s.address] = s.latest_received_at;
}
initialized.value = true;
return;
}
if (!notificationsStore.isGranted)
return;
for (const s of statuses) {
const prev = lastSeen.value[s.address];
if (s.latest_received_at &&
(prev === undefined || prev === null || s.latest_received_at > prev)) {
lastSeen.value[s.address] = s.latest_received_at;
fireNotification(s.address, s.unread_count);
}
}
}
catch {
// network error — skip
}
};
const fireNotification = (address, unreadCount) => {
const n = new Notification(address, {
body: `${unreadCount} unread message${unreadCount !== 1 ? 's' : ''}`,
icon: '/favicon/android-chrome-192x192.png',
tag: address,
data: { mailbox: address },
});
n.onclick = () => {
window.focus();
mailStore.currentMailbox = address;
n.close();
};
};
watch(() => mailStore.mailboxes, (boxes) => {
if (boxes.length > 0 && !initialized.value)
poll();
}, { immediate: true });
const interval = setInterval(poll, 60_000);
onUnmounted(() => clearInterval(interval));
}
//# sourceMappingURL=useMailPolling.js.map

View file

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

View file

@ -0,0 +1,86 @@
import { onUnmounted, ref, watch } from 'vue'
import { mailApi } from '@/api/mail'
import { useNotificationsStore } from '@/stores/notifications'
import { useMailStore } from '@/stores/mail'
interface MailboxStatus {
address: string
unread_count: number
latest_received_at: string | null
}
function updateBadge(count: number) {
if (!('setAppBadge' in navigator)) return
if (count > 0) {
navigator.setAppBadge(count).catch(() => {})
} else {
navigator.clearAppBadge().catch(() => {})
}
}
export function useMailPolling() {
const notificationsStore = useNotificationsStore()
const mailStore = useMailStore()
const lastSeen = ref<Record<string, string | null>>({})
const initialized = ref(false)
const poll = async () => {
if (mailStore.mailboxes.length === 0) return
try {
const res = await mailApi.getMailboxStatus()
const statuses: MailboxStatus[] = res.data
const totalUnread = statuses.reduce((sum, s) => sum + s.unread_count, 0)
updateBadge(totalUnread)
if (!initialized.value) {
for (const s of statuses) {
lastSeen.value[s.address] = s.latest_received_at
}
initialized.value = true
return
}
if (!notificationsStore.isGranted) return
for (const s of statuses) {
const prev = lastSeen.value[s.address]
if (
s.latest_received_at &&
(prev === undefined || prev === null || s.latest_received_at > prev)
) {
lastSeen.value[s.address] = s.latest_received_at
fireNotification(s.address, s.unread_count)
}
}
} catch {
// network error — skip
}
}
const fireNotification = (address: string, unreadCount: number) => {
const n = new Notification(address, {
body: `${unreadCount} unread message${unreadCount !== 1 ? 's' : ''}`,
icon: '/favicon/android-chrome-192x192.png',
tag: address,
data: { mailbox: address },
})
n.onclick = () => {
window.focus()
mailStore.currentMailbox = address
n.close()
}
}
watch(
() => mailStore.mailboxes,
(boxes) => {
if (boxes.length > 0 && !initialized.value) poll()
},
{ immediate: true }
)
const interval = setInterval(poll, 60_000)
onUnmounted(() => clearInterval(interval))
}

View file

@ -0,0 +1,67 @@
import { ref } from 'vue';
import apiClient from '@/api/client';
const isSupported = typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window;
function urlBase64ToUint8Array(base64) {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const raw = atob((base64 + padding).replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++)
bytes[i] = raw.charCodeAt(i);
return bytes;
}
export function usePushSubscription() {
const isSubscribed = ref(false);
const isLoading = ref(false);
const checkSubscription = async () => {
if (!isSupported)
return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
isSubscribed.value = !!sub;
};
const subscribe = async (mailboxAddress) => {
if (!isSupported)
return;
isLoading.value = true;
try {
const { data } = await apiClient.get('/notifications/vapid-key');
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.public_key),
});
const subJson = sub.toJSON();
await apiClient.post('/notifications/subscribe', {
endpoint: subJson.endpoint,
keys: subJson.keys,
mailbox_address: mailboxAddress,
});
isSubscribed.value = true;
}
finally {
isLoading.value = false;
}
};
const unsubscribe = async () => {
if (!isSupported)
return;
isLoading.value = true;
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await apiClient.delete('/notifications/subscribe', {
data: { endpoint: sub.endpoint },
});
await sub.unsubscribe();
}
isSubscribed.value = false;
}
finally {
isLoading.value = false;
}
};
checkSubscription();
return { isSubscribed, isLoading, isSupported, subscribe, unsubscribe };
}
//# sourceMappingURL=usePushSubscription.js.map

View file

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

View file

@ -0,0 +1,68 @@
import { ref } from 'vue'
import apiClient from '@/api/client'
const isSupported = typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const raw = atob((base64 + padding).replace(/-/g, '+').replace(/_/g, '/'))
const bytes = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
return bytes
}
export function usePushSubscription() {
const isSubscribed = ref(false)
const isLoading = ref(false)
const checkSubscription = async () => {
if (!isSupported) return
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
isSubscribed.value = !!sub
}
const subscribe = async (mailboxAddress: string) => {
if (!isSupported) return
isLoading.value = true
try {
const { data } = await apiClient.get('/notifications/vapid-key')
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.public_key),
})
const subJson = sub.toJSON()
await apiClient.post('/notifications/subscribe', {
endpoint: subJson.endpoint,
keys: subJson.keys,
mailbox_address: mailboxAddress,
})
isSubscribed.value = true
} finally {
isLoading.value = false
}
}
const unsubscribe = async () => {
if (!isSupported) return
isLoading.value = true
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await apiClient.delete('/notifications/subscribe', {
data: { endpoint: sub.endpoint },
})
await sub.unsubscribe()
}
isSubscribed.value = false
} finally {
isLoading.value = false
}
}
checkSubscription()
return { isSubscribed, isLoading, isSupported, subscribe, unsubscribe }
}

View file

@ -17,7 +17,25 @@ export const useAuthStore = defineStore('auth', () => {
token.value = newToken;
localStorage.setItem('jwt_token', newToken);
};
const logout = () => {
const logout = async () => {
if ('serviceWorker' in navigator) {
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await fetch('/api/v1/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.value}`,
},
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
}
catch { /* ignore push cleanup errors */ }
}
token.value = '';
localStorage.removeItem('jwt_token');
};

View file

@ -1 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AACnC,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAA;AAEnC,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CAAC,MAAM,EAAE,GAAG,EAAE;IACnD,MAAM,KAAK,GAAG,GAAG,CAAC,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAA;IAE1D,MAAM,eAAe,GAAG,QAAQ,CAAC,GAAG,EAAE;QACpC,IAAI,CAAC,KAAK,CAAC,KAAK;YAAE,OAAO,KAAK,CAAA;QAC9B,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;YAC3D,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;QACzE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAA;QACd,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,MAAM,QAAQ,GAAG,CAAC,QAAgB,EAAE,EAAE;QACpC,KAAK,CAAC,KAAK,GAAG,QAAQ,CAAA;QACtB,YAAY,CAAC,OAAO,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;IAC7C,CAAC,CAAA;IAED,MAAM,MAAM,GAAG,GAAG,EAAE;QAClB,KAAK,CAAC,KAAK,GAAG,EAAE,CAAA;QAChB,YAAY,CAAC,UAAU,CAAC,WAAW,CAAC,CAAA;IACtC,CAAC,CAAA;IAED,MAAM,WAAW,GAAG,GAAG,EAAE;QACvB,IAAI,CAAC,KAAK,CAAC,KAAK;YAAE,OAAO,IAAI,CAAA;QAC7B,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;YACzC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAA;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC,CAAA;IAED,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;AAClE,CAAC,CAAC,CAAA"}
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AACnC,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAA;AAEnC,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CAAC,MAAM,EAAE,GAAG,EAAE;IACnD,MAAM,KAAK,GAAG,GAAG,CAAC,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAA;IAE1D,MAAM,eAAe,GAAG,QAAQ,CAAC,GAAG,EAAE;QACpC,IAAI,CAAC,KAAK,CAAC,KAAK;YAAE,OAAO,KAAK,CAAA;QAC9B,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;YAC3D,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;QACzE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAA;QACd,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,MAAM,QAAQ,GAAG,CAAC,QAAgB,EAAE,EAAE;QACpC,KAAK,CAAC,KAAK,GAAG,QAAQ,CAAA;QACtB,YAAY,CAAC,OAAO,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;IAC7C,CAAC,CAAA;IAED,MAAM,MAAM,GAAG,KAAK,IAAI,EAAE;QACxB,IAAI,eAAe,IAAI,SAAS,EAAE,CAAC;YACjC,IAAI,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,aAAa,CAAC,KAAK,CAAA;gBAC/C,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,WAAW,CAAC,eAAe,EAAE,CAAA;gBACnD,IAAI,GAAG,EAAE,CAAC;oBACR,MAAM,KAAK,CAAC,iCAAiC,EAAE;wBAC7C,MAAM,EAAE,QAAQ;wBAChB,OAAO,EAAE;4BACP,cAAc,EAAE,kBAAkB;4BAClC,eAAe,EAAE,UAAU,KAAK,CAAC,KAAK,EAAE;yBACzC;wBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC;qBACjD,CAAC,CAAA;oBACF,MAAM,GAAG,CAAC,WAAW,EAAE,CAAA;gBACzB,CAAC;YACH,CAAC;YAAC,MAAM,CAAC,CAAC,gCAAgC,CAAC,CAAC;QAC9C,CAAC;QACD,KAAK,CAAC,KAAK,GAAG,EAAE,CAAA;QAChB,YAAY,CAAC,UAAU,CAAC,WAAW,CAAC,CAAA;IACtC,CAAC,CAAA;IAED,MAAM,WAAW,GAAG,GAAG,EAAE;QACvB,IAAI,CAAC,KAAK,CAAC,KAAK;YAAE,OAAO,IAAI,CAAA;QAC7B,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;YACzC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAA;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC,CAAA;IAED,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;AAClE,CAAC,CAAC,CAAA"}

View file

@ -1,9 +1,10 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { setIDBItem, removeIDBItem } from '@/lib/idb'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('jwt_token') || '')
const isAuthenticated = computed(() => {
if (!token.value) return false
try {
@ -13,15 +14,34 @@ export const useAuthStore = defineStore('auth', () => {
return false
}
})
const setToken = (newToken: string) => {
token.value = newToken
localStorage.setItem('jwt_token', newToken)
setIDBItem('jwt_token', newToken).catch(() => {})
}
const logout = () => {
const logout = async () => {
if ('serviceWorker' in navigator) {
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await fetch('/api/v1/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.value}`,
},
body: JSON.stringify({ endpoint: sub.endpoint }),
})
await sub.unsubscribe()
}
} catch { /* ignore push cleanup errors */ }
}
token.value = ''
localStorage.removeItem('jwt_token')
removeIDBItem('jwt_token').catch(() => {})
}
const decodeToken = () => {
@ -35,4 +55,4 @@ export const useAuthStore = defineStore('auth', () => {
}
return { token, isAuthenticated, setToken, logout, decodeToken }
})
})

View file

@ -0,0 +1,15 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useNotificationsStore = defineStore('notifications', () => {
const permission = ref(typeof Notification !== 'undefined' ? Notification.permission : 'denied');
const isGranted = computed(() => permission.value === 'granted');
const isDenied = computed(() => permission.value === 'denied');
async function requestPermission() {
if (typeof Notification === 'undefined')
return;
const result = await Notification.requestPermission();
permission.value = result;
}
return { permission, isGranted, isDenied, requestPermission };
});
//# sourceMappingURL=notifications.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"notifications.js","sourceRoot":"","sources":["notifications.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AACnC,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAA;AAEnC,MAAM,CAAC,MAAM,qBAAqB,GAAG,WAAW,CAAC,eAAe,EAAE,GAAG,EAAE;IACrE,MAAM,UAAU,GAAG,GAAG,CACpB,OAAO,YAAY,KAAK,WAAW,CAAC,CAAC,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CACzE,CAAA;IAED,MAAM,SAAS,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,KAAK,SAAS,CAAC,CAAA;IAChE,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAA;IAE9D,KAAK,UAAU,iBAAiB;QAC9B,IAAI,OAAO,YAAY,KAAK,WAAW;YAAE,OAAM;QAC/C,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,iBAAiB,EAAE,CAAA;QACrD,UAAU,CAAC,KAAK,GAAG,MAAM,CAAA;IAC3B,CAAC;IAED,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,iBAAiB,EAAE,CAAA;AAC/D,CAAC,CAAC,CAAA"}

View file

@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useNotificationsStore = defineStore('notifications', () => {
const permission = ref<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied'
)
const isGranted = computed(() => permission.value === 'granted')
const isDenied = computed(() => permission.value === 'denied')
async function requestPermission() {
if (typeof Notification === 'undefined') return
const result = await Notification.requestPermission()
permission.value = result
}
return { permission, isGranted, isDenied, requestPermission }
})

89
webmail/src/sw.ts Normal file
View file

@ -0,0 +1,89 @@
/// <reference lib="webworker" />
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { setCatchHandler } from 'workbox-routing'
import { getIDBItem } from './lib/idb'
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
}
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
async function broadcastBadge(count: number) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
for (const client of clients) {
client.postMessage({ type: 'SET_BADGE', count })
}
}
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {}
const unreadCount: number = data.unread_count ?? 0
const notifOptions: NotificationOptions = {
body: data.subject ?? 'New message',
icon: '/favicon/android-chrome-192x192.png',
badge: '/favicon/favicon-32x32.png',
tag: String(data.message_id ?? Date.now()),
data: { messageId: data.message_id, mailbox: data.mailbox, unreadCount },
actions: [{ action: 'mark-read', title: 'Mark as Read' }],
}
event.waitUntil(
self.registration
.showNotification(data.mailbox ?? 'DockFlare Mail', notifOptions)
.then(() => broadcastBadge(unreadCount))
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const { messageId, mailbox, unreadCount } = event.notification.data ?? {}
if (event.action === 'mark-read' && messageId && mailbox) {
event.waitUntil(
(async () => {
const token = await getIDBItem('jwt_token')
if (token) {
try {
await fetch(`/api/v1/mailboxes/${mailbox}/messages/${messageId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ is_read: true }),
})
} catch { /* ignore */ }
}
await broadcastBadge(Math.max(0, (unreadCount ?? 1) - 1))
})()
)
return
}
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if ('focus' in client) {
client.postMessage({ type: 'NOTIFICATION_CLICK', messageId, mailbox })
return (client as WindowClient).focus()
}
}
const url = mailbox
? `/?mailbox=${encodeURIComponent(mailbox)}&message=${encodeURIComponent(messageId ?? '')}`
: '/'
return self.clients.openWindow(url)
})
)
})
setCatchHandler(async ({ request }) => {
if (request.destination === 'document') {
return (await caches.match('/offline.html')) ?? Response.error()
}
return Response.error()
})

View file

@ -31,8 +31,7 @@ const handleLogin = async () => {
error.value = '';
loading.value = true;
try {
const masterUrl = await getMasterUrl();
const data = await authApi.loginWithPassword(masterUrl, email.value, password.value);
const data = await authApi.loginWithPassword(email.value, password.value);
if (data.success && data.token) {
login(data.token);
}

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,14 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMail } from '../composables/useMail'
import { useMailPolling } from '../composables/useMailPolling'
import { mailApi } from '../api/mail'
import MailLayout from '../components/mail/MailLayout.vue'
const route = useRoute()
const { store, loadMailboxes } = useMail()
useMailPolling()
const loadMessages = async (addr: string, folder: string) => {
if (!addr || !folder) return
@ -18,8 +22,32 @@ const loadMessages = async (addr: string, folder: string) => {
}
}
onMounted(() => {
loadMailboxes()
onMounted(async () => {
await loadMailboxes()
const mailboxParam = route.query.mailbox as string | undefined
if (mailboxParam) {
const found = store.mailboxes.find((b: any) => b.address === mailboxParam)
if (found) store.currentMailbox = mailboxParam
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (ev: MessageEvent) => {
if (ev.data?.type === 'NOTIFICATION_CLICK' && ev.data.mailbox) {
store.currentMailbox = ev.data.mailbox
}
if (ev.data?.type === 'SET_BADGE') {
const count: number = ev.data.count ?? 0
if ('setAppBadge' in navigator) {
if (count > 0) {
navigator.setAppBadge(count).catch(() => {})
} else {
navigator.clearAppBadge().catch(() => {})
}
}
}
})
}
})
watch(() => store.currentMailbox, async (addr) => {

View file

@ -1,9 +1,13 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMail } from '../composables/useMail';
import { useMailPolling } from '../composables/useMailPolling';
import { mailApi } from '../api/mail';
import MailLayout from '../components/mail/MailLayout.vue';
const route = useRoute();
const { store, loadMailboxes } = useMail();
useMailPolling();
const loadMessages = async (addr, folder) => {
if (!addr || !folder)
return;
@ -17,8 +21,21 @@ const loadMessages = async (addr, folder) => {
console.error('Failed to load messages', e);
}
};
onMounted(() => {
loadMailboxes();
onMounted(async () => {
await loadMailboxes();
const mailboxParam = route.query.mailbox;
if (mailboxParam) {
const found = store.mailboxes.find((b) => b.address === mailboxParam);
if (found)
store.currentMailbox = mailboxParam;
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (ev) => {
if (ev.data?.type === 'NOTIFICATION_CLICK' && ev.data.mailbox) {
store.currentMailbox = ev.data.mailbox;
}
});
}
});
watch(() => store.currentMailbox, async (addr) => {
if (!addr)

View file

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

View file

@ -1,9 +1,130 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useInstallPrompt } from '@/composables/useInstallPrompt'
import { useNotificationsStore } from '@/stores/notifications'
import { usePushSubscription } from '@/composables/usePushSubscription'
import { useMailStore } from '@/stores/mail'
import { mailApi } from '@/api/mail'
const { canInstall, promptInstall } = useInstallPrompt()
const notificationsStore = useNotificationsStore()
const push = usePushSubscription()
const mailStore = useMailStore()
const notificationPreview = ref(true)
const previewLoading = ref(false)
watch(
() => mailStore.currentMailbox,
async (address) => {
if (!address) return
try {
const res = await mailApi.getMailboxPreferences(address)
notificationPreview.value = res.data.notification_preview
} catch { /* ignore */ }
},
{ immediate: true }
)
async function togglePreview() {
if (!mailStore.currentMailbox || previewLoading.value) return
previewLoading.value = true
const next = !notificationPreview.value
try {
await mailApi.updateMailboxPreferences(mailStore.currentMailbox, { notification_preview: next })
notificationPreview.value = next
} catch { /* ignore */ } finally {
previewLoading.value = false
}
}
</script>
<template>
<div class="p-8">
<h1 class="text-2xl font-semibold mb-4">Settings</h1>
<p class="text-muted-foreground">Settings are managed in DockFlare Master.</p>
<div class="p-8 max-w-2xl">
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
<div v-if="canInstall" class="mb-4 rounded-lg border p-4 space-y-3">
<div>
<h2 class="text-sm font-medium">Install App</h2>
<p class="text-sm text-muted-foreground mt-0.5">
Install DockFlare Mail as a desktop app for faster access and desktop notifications.
</p>
</div>
<button
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
@click="promptInstall"
>
Install
</button>
</div>
<div class="mb-4 rounded-lg border p-4 space-y-3">
<div>
<h2 class="text-sm font-medium">Notifications</h2>
<p class="text-sm text-muted-foreground mt-0.5">
Get notified when new mail arrives, even when the app is closed.
</p>
</div>
<template v-if="notificationsStore.isDenied">
<p class="text-sm text-muted-foreground">
Notifications are blocked. Enable them in your browser or OS settings.
</p>
</template>
<template v-else-if="!notificationsStore.isGranted">
<button
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
@click="notificationsStore.requestPermission"
>
Enable Notifications
</button>
</template>
<template v-else>
<p class="text-sm text-muted-foreground">Permission granted.</p>
<div v-if="push.isSupported && mailStore.currentMailbox" class="flex items-center gap-3">
<span class="text-sm">Background push for {{ mailStore.currentMailbox }}</span>
<button
v-if="!push.isSubscribed.value"
:disabled="push.isLoading.value"
class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
@click="push.subscribe(mailStore.currentMailbox)"
>
Enable
</button>
<button
v-else
:disabled="push.isLoading.value"
class="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
@click="push.unsubscribe()"
>
Disable
</button>
</div>
<p v-else-if="!push.isSupported" class="text-sm text-muted-foreground">
Background push is not supported in this browser.
</p>
<div v-if="mailStore.currentMailbox" class="flex items-center gap-3 pt-1">
<span class="text-sm">Show subject &amp; sender in notifications</span>
<button
:disabled="previewLoading"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 disabled:opacity-50"
:class="notificationPreview ? 'bg-primary' : 'bg-muted'"
role="switch"
:aria-checked="notificationPreview"
@click="togglePreview"
>
<span
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200"
:class="notificationPreview ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</template>
</div>
<p class="text-sm text-muted-foreground">Additional settings are managed in DockFlare Master.</p>
</div>
</template>
</template>

View file

@ -1,26 +1,211 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { useInstallPrompt } from '@/composables/useInstallPrompt';
import { useNotificationsStore } from '@/stores/notifications';
import { usePushSubscription } from '@/composables/usePushSubscription';
import { useMailStore } from '@/stores/mail';
const { canInstall, promptInstall } = useInstallPrompt();
const notificationsStore = useNotificationsStore();
const push = usePushSubscription();
const mailStore = useMailStore();
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;
let __VLS_directives;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "p-8" },
...{ class: "p-8 max-w-2xl" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({
...{ class: "text-2xl font-semibold mb-4" },
...{ class: "text-2xl font-semibold mb-6" },
});
if (__VLS_ctx.canInstall) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "mb-4 rounded-lg border p-4 space-y-3" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({
...{ class: "text-sm font-medium" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-sm text-muted-foreground mt-0.5" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.promptInstall) },
...{ class: "inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors" },
});
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "mb-4 rounded-lg border p-4 space-y-3" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({
...{ class: "text-sm font-medium" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-muted-foreground" },
...{ class: "text-sm text-muted-foreground mt-0.5" },
});
if (__VLS_ctx.notificationsStore.isDenied) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-sm text-muted-foreground" },
});
}
else if (!__VLS_ctx.notificationsStore.isGranted) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.notificationsStore.requestPermission) },
...{ class: "inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors" },
});
}
else {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-sm text-muted-foreground" },
});
if (__VLS_ctx.push.isSupported && __VLS_ctx.mailStore.currentMailbox) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex items-center gap-3" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-sm" },
});
(__VLS_ctx.mailStore.currentMailbox);
if (!__VLS_ctx.push.isSubscribed.value) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.notificationsStore.isDenied))
return;
if (!!(!__VLS_ctx.notificationsStore.isGranted))
return;
if (!(__VLS_ctx.push.isSupported && __VLS_ctx.mailStore.currentMailbox))
return;
if (!(!__VLS_ctx.push.isSubscribed.value))
return;
__VLS_ctx.push.subscribe(__VLS_ctx.mailStore.currentMailbox);
} },
disabled: (__VLS_ctx.push.isLoading.value),
...{ class: "inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50" },
});
}
else {
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.notificationsStore.isDenied))
return;
if (!!(!__VLS_ctx.notificationsStore.isGranted))
return;
if (!(__VLS_ctx.push.isSupported && __VLS_ctx.mailStore.currentMailbox))
return;
if (!!(!__VLS_ctx.push.isSubscribed.value))
return;
__VLS_ctx.push.unsubscribe();
} },
disabled: (__VLS_ctx.push.isLoading.value),
...{ class: "inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50" },
});
}
}
else if (!__VLS_ctx.push.isSupported) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-sm text-muted-foreground" },
});
}
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-sm text-muted-foreground" },
});
/** @type {__VLS_StyleScopedClasses['p-8']} */ ;
/** @type {__VLS_StyleScopedClasses['max-w-2xl']} */ ;
/** @type {__VLS_StyleScopedClasses['text-2xl']} */ ;
/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-6']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-4']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-lg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
/** @type {__VLS_StyleScopedClasses['space-y-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['mt-0.5']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
/** @type {__VLS_StyleScopedClasses['text-primary-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-primary/90']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-4']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-lg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
/** @type {__VLS_StyleScopedClasses['space-y-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['mt-0.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
/** @type {__VLS_StyleScopedClasses['text-primary-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-primary/90']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
/** @type {__VLS_StyleScopedClasses['text-primary-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-primary/90']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['disabled:opacity-50']} */ ;
/** @type {__VLS_StyleScopedClasses['inline-flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-md']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-medium']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:bg-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-colors']} */ ;
/** @type {__VLS_StyleScopedClasses['disabled:opacity-50']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {};
return {
canInstall: canInstall,
promptInstall: promptInstall,
notificationsStore: notificationsStore,
push: push,
mailStore: mailStore,
};
},
});
export default (await import('vue')).defineComponent({

File diff suppressed because one or more lines are too long

View file

@ -12,11 +12,12 @@
"esModuleInterop": true,
"lib": ["ES2022", "DOM"],
"skipLibCheck": true,
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-pwa/client"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["src/sw.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -1,12 +1,49 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
manifest: {
name: 'DockFlare Mail',
short_name: 'Mail',
display: 'standalone',
display_override: ['window-controls-overlay', 'standalone'],
start_url: '/',
scope: '/',
theme_color: '#0f172a',
background_color: '#ffffff',
icons: [
{
src: '/favicon/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/favicon/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/favicon/apple-touch-icon.png',
sizes: '180x180',
type: 'image/png'
}
]
}
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
})