feat: rebuild email platform and operations console

This commit is contained in:
2026-07-25 23:06:15 +08:00
parent c109abc6f5
commit 32e0969ca1
443 changed files with 56446 additions and 1585 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.env.local
*.log
+5
View File
@@ -0,0 +1,5 @@
VITE_APP_DOMAIN=mail.shenjianl.cn
VITE_API_BASE_URL=
VITE_API_PROXY_TARGET=http://127.0.0.1:3000
VITE_REPO_URL=
VITE_REPO_NAME=
+13 -12
View File
@@ -1,19 +1,20 @@
FROM node:20-alpine
FROM node:22-alpine AS builder
# Set working directory
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.3.0 --activate
# Copy package.json and package-lock.json
COPY package*.json ./
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Install dependencies
RUN npm install
# Copy all other files
COPY . .
RUN pnpm build
# Expose port
EXPOSE 8080
FROM nginx:1.27-alpine
# Run the app
CMD ["npm", "run", "serve"]
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1
+12 -12
View File
@@ -1,19 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/logo.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#000000">
<meta name="mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<title>临时邮件</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#20231f" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
<title>Email Unlimit</title>
</head>
<body>
<noscript>
<strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong>请启用 JavaScript 后使用 Email Unlimit。</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location ~ ^/(auth|api)(/|$) {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_read_timeout 600s;
}
location = /health {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2 -4
View File
@@ -9,14 +9,12 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.11.0",
"dompurify": "^3.4.12",
"element-plus": "^2.10.4",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1",
"ws": "^8.18.3",
"marked": "^13.0.2"
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
+4285
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false
+3 -3
View File
@@ -4,11 +4,11 @@
</template>
<script>
import { useI18n } from 'vue-i18n';
import AppHeader from './components/AppHeader.vue';
import { useI18n } from "vue-i18n";
import AppHeader from "./components/AppHeader.vue";
export default {
name: 'App',
name: "App",
components: {
AppHeader,
},
-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753688160727" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3424" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M972.8 934.274509 998.4 908.54902 127.868001 908.54902C85.502225 908.54902 51.2 874.019706 51.2 831.263017L51.2 111.058823 25.6 136.784314 896.131998 136.784314C938.452011 136.784314 972.8 171.324278 972.8 213.860934L972.8 278.566694C972.8 292.77449 984.26151 304.292183 998.4 304.292183 1012.53849 304.292183 1024 292.77449 1024 278.566694L1024 213.860934C1024 142.916556 966.736828 85.333333 896.131998 85.333333L25.6 85.333333 0 85.333333 0 111.058823 0 831.263017C0 902.415639 57.205646 960 127.868001 960L998.4 960 1024 960 1024 934.274509 1024 457.69849C1024 443.490694 1012.53849 431.973001 998.4 431.973001 984.26151 431.973001 972.8 443.490694 972.8 457.69849L972.8 934.274509ZM512.651558 567.164817C520.64791 572.874498 531.187889 573.490406 539.788919 568.750601L1002.624789 313.693694C1015.021598 306.862133 1019.560139 291.225196 1012.761901 278.767619 1005.963665 266.310041 990.403006 261.749254 978.006197 268.580816L515.170327 523.637722 542.307689 525.223505 194.028065 276.539467C182.502788 268.310009 166.520953 271.027598 158.331639 282.609372 150.142325 294.191146 152.846657 310.251322 164.371935 318.480781L512.651558 567.164817Z" fill="#000000" p-id="3425"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+1026 -403
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
<svg t="1753714773086" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2330" width="64" height="64"><path d="M400.896 261.156571l20.041143-100.205714A18.285714 18.285714 0 0 1 438.857143 146.285714h146.285714a18.285714 18.285714 0 0 1 17.92 14.701715l20.041143 100.169142c17.846857 7.899429 34.779429 17.700571 50.541714 29.220572l96.804572-32.768a18.285714 18.285714 0 0 1 21.686857 8.192l73.142857 126.683428a18.285714 18.285714 0 0 1-3.766857 22.893715l-76.763429 67.474285a277.211429 277.211429 0 0 1 0 58.294858l76.8 67.474285a18.285714 18.285714 0 0 1 3.730286 22.893715l-73.142857 126.683428a18.285714 18.285714 0 0 1-21.686857 8.192l-96.804572-32.768c-15.762286 11.52-32.694857 21.321143-50.541714 29.220572l-20.041143 100.205714A18.285714 18.285714 0 0 1 585.142857 877.714286h-146.285714a18.285714 18.285714 0 0 1-17.92-14.701715l-20.041143-100.169142a273.993143 273.993143 0 0 1-50.541714-29.220572l-96.804572 32.768a18.285714 18.285714 0 0 1-21.686857-8.192l-73.142857-126.683428a18.285714 18.285714 0 0 1 3.766857-22.893715l76.763429-67.474285a277.211429 277.211429 0 0 1 0-58.294858l-76.8-67.474285a18.285714 18.285714 0 0 1-3.730286-22.893715l73.142857-126.683428a18.285714 18.285714 0 0 1 21.686857-8.192l96.804572 32.768c15.762286-11.52 32.694857-21.321143 50.541714-29.220572zM603.428571 512a91.428571 91.428571 0 1 0-182.857142 0 91.428571 91.428571 0 0 0 182.857142 0z m36.571429 0a128 128 0 1 1-256 0 128 128 0 0 1 256 0z m-205.165714-234.166857a18.285714 18.285714 0 0 1-11.117715 13.385143 237.421714 237.421714 0 0 0-58.697142 33.938285 18.285714 18.285714 0 0 1-17.188572 2.962286l-91.794286-31.085714-58.148571 100.754286 72.777143 63.963428a18.285714 18.285714 0 0 1 6.034286 16.310857 239.908571 239.908571 0 0 0 0 67.876572 18.285714 18.285714 0 0 1-6.034286 16.310857l-72.777143 63.963428L256 726.930286l91.794286-31.085715a18.285714 18.285714 0 0 1 17.188571 2.998858 237.421714 237.421714 0 0 0 58.697143 33.938285 18.285714 18.285714 0 0 1 11.154286 13.385143L453.851429 841.142857h116.297142l19.017143-94.976a18.285714 18.285714 0 0 1 11.117715-13.385143 237.421714 237.421714 0 0 0 58.697142-33.938285 18.285714 18.285714 0 0 1 17.188572-2.962286l91.794286 31.085714 58.148571-100.754286-72.777143-63.963428a18.285714 18.285714 0 0 1-6.034286-16.310857 239.908571 239.908571 0 0 0 0-67.876572 18.285714 18.285714 0 0 1 6.034286-16.310857l72.777143-63.963428L768 297.069714l-91.794286 31.085715a18.285714 18.285714 0 0 1-17.188571-2.998858 237.421714 237.421714 0 0 0-58.697143-33.938285 18.285714 18.285714 0 0 1-11.154286-13.385143L570.148571 182.857143h-116.297142l-19.017143 94.976z" fill="#000000" p-id="2331"></path></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

+49 -41
View File
@@ -1,53 +1,61 @@
<script setup>
import { Setting, SwitchButton } from "@element-plus/icons-vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const router = useRouter();
const { t } = useI18n();
const repoUrl = import.meta.env.VITE_REPO_URL;
const repoName = import.meta.env.VITE_REPO_NAME;
async function logout() {
await auth.logout();
await router.push("/");
}
</script>
<template>
<header class="app-header">
<router-link to="/" class="logo-link">
<div class="logo">Email Unlimit</div>
<router-link to="/" class="brand-link">
<span class="brand-mark">EU</span>
<span>
<strong>Email Unlimit</strong>
<small>{{ t("header.productLabel") }}</small>
</span>
</router-link>
<nav class="nav-links">
<a :href="repoUrl" target="_blank">{{ repoName }}</a>
<router-link to="/settings" class="settings-link" title="设置">
<img src="@/assets/setting.svg" alt="Settings" class="settings-icon" />
<nav class="nav-links" :aria-label="t('header.navigation')">
<a
v-if="repoUrl && repoName"
:href="repoUrl"
target="_blank"
rel="noreferrer"
>
{{ repoName }}
</a>
<span v-if="auth.isAuthenticated" class="account-email">{{
auth.email
}}</span>
<router-link
to="/settings"
class="icon-command"
:title="t('header.settings')"
:aria-label="t('header.settings')"
>
<el-icon><Setting /></el-icon>
</router-link>
<button
v-if="auth.isAuthenticated"
class="icon-command"
type="button"
:title="t('auth.logout')"
:aria-label="t('auth.logout')"
@click="logout"
>
<el-icon><SwitchButton /></el-icon>
</button>
</nav>
</header>
</template>
<script>
export default {
name: 'AppHeader',
};
</script>
<style scoped>
.logo-link {
text-decoration: none;
}
.nav-links {
display: flex;
align-items: center;
gap: 2rem;
}
.settings-link {
display: flex;
align-items: center;
}
.settings-icon {
width: 24px;
height: 24px;
transition: filter 0.3s ease;
}
.settings-link:hover .settings-icon {
/* Generated by https://codepen.io/sosuke/pen/Pjoqqp */
/* This filter converts black to the --primary-purple color */
filter: brightness(0) saturate(100%) invert(20%) sepia(80%) saturate(3750%) hue-rotate(261deg) brightness(95%) contrast(94%);
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { reactive, ref } from "vue";
import { Lock, Message } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const { t } = useI18n();
const mode = ref("login");
const form = reactive({
email: "",
password: "",
invite_code: "",
});
async function submit() {
try {
if (mode.value === "login") {
await auth.login({ email: form.email, password: form.password });
ElMessage.success(t("auth.loginSuccess"));
} else {
await auth.register({
email: form.email,
password: form.password,
invite_code: form.invite_code || null,
});
ElMessage.success(t("auth.registerSuccess"));
}
} catch (error) {
ElMessage.error(error.message || t("common.requestFailed"));
}
}
</script>
<template>
<main class="auth-shell">
<section class="auth-intro">
<p class="auth-kicker">{{ t("auth.kicker") }}</p>
<h1>Email Unlimit</h1>
<p>{{ t("auth.description") }}</p>
<ul>
<li>{{ t("auth.featureMailbox") }}</li>
<li>{{ t("auth.featureRealtime") }}</li>
<li>{{ t("auth.featurePrivacy") }}</li>
</ul>
</section>
<section class="auth-form-wrap" aria-labelledby="auth-title">
<div class="segmented-control" role="tablist">
<button
type="button"
:class="{ active: mode === 'login' }"
role="tab"
:aria-selected="mode === 'login'"
@click="mode = 'login'"
>
{{ t("auth.login") }}
</button>
<button
type="button"
:class="{ active: mode === 'register' }"
role="tab"
:aria-selected="mode === 'register'"
@click="mode = 'register'"
>
{{ t("auth.register") }}
</button>
</div>
<h2 id="auth-title">
{{ mode === "login" ? t("auth.welcomeBack") : t("auth.createAccount") }}
</h2>
<p class="form-hint">
{{ mode === "login" ? t("auth.loginHint") : t("auth.registerHint") }}
</p>
<form class="auth-form" @submit.prevent="submit">
<label>
<span>{{ t("auth.email") }}</span>
<div class="field-with-icon">
<el-icon><Message /></el-icon>
<input
v-model.trim="form.email"
type="email"
autocomplete="email"
required
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
</label>
<label>
<span>{{ t("auth.password") }}</span>
<div class="field-with-icon">
<el-icon><Lock /></el-icon>
<input
v-model="form.password"
type="password"
:autocomplete="
mode === 'login' ? 'current-password' : 'new-password'
"
minlength="8"
maxlength="128"
required
:placeholder="t('auth.passwordPlaceholder')"
/>
</div>
</label>
<label v-if="mode === 'register'">
<span>{{ t("auth.inviteCode") }}</span>
<input
v-model.trim="form.invite_code"
type="text"
autocomplete="off"
:placeholder="t('auth.invitePlaceholder')"
/>
</label>
<button class="primary-command" type="submit" :disabled="auth.loading">
{{
auth.loading
? t("common.processing")
: mode === "login"
? t("auth.login")
: t("auth.register")
}}
</button>
</form>
</section>
</main>
</template>
+857
View File
@@ -0,0 +1,857 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
import {
Bell,
BellFilled,
CircleCheck,
CopyDocument,
Delete,
Download,
MessageBox,
Plus,
Refresh,
Tickets,
Wallet,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import DOMPurify from "dompurify";
import { useI18n } from "vue-i18n";
import { apiRequest, websocketUrl } from "@/lib/api";
import {
getMailboxToken,
removeMailboxToken,
setMailboxToken,
} from "@/lib/mailbox-tokens";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const { locale, t } = useI18n();
const configuredDomain = import.meta.env.VITE_APP_DOMAIN || "mail.shenjianl.cn";
const loading = ref(true);
const mailboxLoading = ref(false);
const messageLoading = ref(false);
const detailLoading = ref(false);
const mailboxes = ref([]);
const selectedMailboxId = ref(null);
const messages = ref([]);
const selectedMessage = ref(null);
const messageDetail = ref(null);
const hasMoreMessages = ref(false);
const createDialogOpen = ref(false);
const creditsDrawerOpen = ref(false);
const transactions = ref([]);
const notificationEnabled = ref(
typeof Notification !== "undefined" && Notification.permission === "granted",
);
const createForm = reactive({
local_part: "",
note: "",
ttl_hours: 24,
});
const credit = ref({
enabled: false,
balance: 0,
total_granted: 0,
total_consumed: 0,
pricing: { create_mailbox: 0, receive_email: 0 },
daily_limits: { mailboxes: 0, emails: 0 },
usage_today: { mailboxes_created: 0, emails_received: 0, bytes_received: 0 },
remaining_today: { mailboxes: 0, emails: 0 },
check_in: {
claimed: false,
available: false,
reward: 0,
reward_granted: null,
reward_balance_cap: 0,
date: "",
},
});
let socket = null;
let reconnectTimer = null;
const selectedMailbox = computed(() =>
mailboxes.value.find((mailbox) => mailbox.id === selectedMailboxId.value),
);
const sanitizedBody = computed(() => {
if (!messageDetail.value) return "";
if (messageDetail.value.html_body) {
return DOMPurify.sanitize(messageDetail.value.html_body, {
FORBID_TAGS: [
"script",
"style",
"iframe",
"object",
"embed",
"form",
"img",
"svg",
"math",
],
FORBID_ATTR: ["style"],
});
}
const text = messageDetail.value.text_body || t("mail.noBody");
return `<pre>${escapeHtml(text)}</pre>`;
});
const mailboxUsagePercent = computed(() => {
const mailbox = selectedMailbox.value;
if (!mailbox?.max_quota_bytes) return 0;
return Math.min(
100,
Math.round((mailbox.used_bytes / mailbox.max_quota_bytes) * 100),
);
});
const canCreateMailbox = computed(
() =>
credit.value.remaining_today.mailboxes > 0 &&
credit.value.balance >= credit.value.pricing.create_mailbox,
);
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function formatDate(value) {
if (!value) return "—";
return new Intl.DateTimeFormat(locale.value === "zh" ? "zh-CN" : "en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatBytes(value = 0) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function transactionReason(reason) {
const key = `credits.reasons.${reason}`;
const translated = t(key);
return translated === key ? reason : translated;
}
async function loadDashboard() {
loading.value = true;
try {
await Promise.all([loadCredit(), loadMailboxes()]);
} catch (error) {
if (error.status === 401) await auth.logout();
ElMessage.error(error.message || t("common.requestFailed"));
} finally {
loading.value = false;
}
}
async function loadCredit() {
credit.value = await apiRequest("/api/credits");
}
async function loadTransactions() {
transactions.value = await apiRequest(
"/api/credits/transactions?limit=100&offset=0",
);
}
async function showTransactions() {
creditsDrawerOpen.value = true;
try {
await loadTransactions();
} catch (error) {
ElMessage.error(error.message || t("common.requestFailed"));
}
}
async function checkIn() {
try {
const result = await apiRequest("/api/credits/check-in", {
method: "POST",
});
await Promise.all([loadCredit(), loadTransactions()]);
if (result.claimed) {
ElMessage.success(
t("credits.checkInSuccess", { reward: result.reward_granted }),
);
} else {
ElMessage.info(t("credits.checkedIn"));
}
} catch (error) {
ElMessage.error(error.message || t("credits.checkInFailed"));
}
}
async function loadMailboxes() {
mailboxLoading.value = true;
try {
mailboxes.value = await apiRequest("/api/mailboxes");
const active = mailboxes.value.find(
(mailbox) => mailbox.status === "active",
);
if (active && !selectedMailboxId.value) {
await selectMailbox(active);
}
} finally {
mailboxLoading.value = false;
}
}
async function ensureMailboxToken(mailbox, forceRotate = false) {
let token = forceRotate ? null : getMailboxToken(auth.email, mailbox.id);
if (!token) {
const result = await apiRequest(
`/api/mailboxes/${mailbox.id}/rotate-token`,
{
method: "POST",
},
);
token = result.access_token;
setMailboxToken(auth.email, mailbox.id, token);
}
return token;
}
async function mailboxRequest(mailbox, path, options = {}, canRetry = true) {
const token = await ensureMailboxToken(mailbox);
try {
return await apiRequest(path, {
...options,
auth: "mailbox",
mailboxToken: token,
});
} catch (error) {
if (error.status === 401 && canRetry) {
removeMailboxToken(auth.email, mailbox.id);
const nextToken = await ensureMailboxToken(mailbox, true);
return apiRequest(path, {
...options,
auth: "mailbox",
mailboxToken: nextToken,
});
}
throw error;
}
}
async function selectMailbox(mailbox) {
if (mailbox.status !== "active") {
ElMessage.warning(t(`mailbox.status.${mailbox.status}`));
return;
}
selectedMailboxId.value = mailbox.id;
selectedMessage.value = null;
messageDetail.value = null;
messages.value = [];
closeSocket();
await fetchMessages(true);
await openSocket(mailbox);
}
async function fetchMessages(reset = true) {
const mailbox = selectedMailbox.value;
if (!mailbox) return;
messageLoading.value = true;
try {
const offset = reset ? 0 : messages.value.length;
const result = await mailboxRequest(
mailbox,
`/api/mailboxes/${mailbox.id}/emails?limit=50&offset=${offset}`,
);
messages.value = reset ? result : [...messages.value, ...result];
hasMoreMessages.value = result.length === 50;
if (reset && result.length > 0) {
await selectMessage(result[0]);
}
} catch (error) {
ElMessage.error(error.message || t("mail.fetchFailed"));
} finally {
messageLoading.value = false;
}
}
async function selectMessage(message) {
const mailbox = selectedMailbox.value;
if (!mailbox) return;
selectedMessage.value = message;
detailLoading.value = true;
try {
messageDetail.value = await mailboxRequest(
mailbox,
`/api/mailboxes/${mailbox.id}/emails/${message.id}`,
);
} catch (error) {
ElMessage.error(error.message || t("mail.fetchDetailFailed"));
} finally {
detailLoading.value = false;
}
}
async function createMailbox() {
try {
const body = {
local_part: createForm.local_part || null,
note: createForm.note || null,
ttl_hours: createForm.ttl_hours,
};
if (configuredDomain) body.domain = configuredDomain;
const created = await apiRequest("/api/mailboxes", {
method: "POST",
body,
});
setMailboxToken(auth.email, created.id, created.access_token);
mailboxes.value.unshift(created);
createDialogOpen.value = false;
createForm.local_part = "";
createForm.note = "";
await Promise.all([
selectMailbox(created),
loadCredit(),
loadTransactions(),
]);
ElMessage.success(t("mailbox.created"));
} catch (error) {
ElMessage.error(error.message || t("mailbox.createFailed"));
}
}
async function revokeMailbox(mailbox) {
try {
await ElMessageBox.confirm(
t("mailbox.revokeConfirm", { address: mailbox.address }),
t("mailbox.revoke"),
{ type: "warning" },
);
await apiRequest(`/api/mailboxes/${mailbox.id}`, { method: "DELETE" });
removeMailboxToken(auth.email, mailbox.id);
if (selectedMailboxId.value === mailbox.id) {
selectedMailboxId.value = null;
messages.value = [];
messageDetail.value = null;
closeSocket();
}
await loadMailboxes();
ElMessage.success(t("mailbox.revoked"));
} catch (error) {
if (error !== "cancel" && error !== "close") {
ElMessage.error(error.message || t("mailbox.revokeFailed"));
}
}
}
async function deleteMessage() {
const mailbox = selectedMailbox.value;
const message = messageDetail.value;
if (!mailbox || !message) return;
try {
await ElMessageBox.confirm(t("mail.deleteConfirm"), t("mail.delete"), {
type: "warning",
});
await mailboxRequest(
mailbox,
`/api/mailboxes/${mailbox.id}/emails/${message.id}`,
{ method: "DELETE" },
);
messages.value = messages.value.filter((item) => item.id !== message.id);
selectedMessage.value = null;
messageDetail.value = null;
const next = messages.value[0];
if (next) await selectMessage(next);
await loadMailboxes();
ElMessage.success(t("mail.deleted"));
} catch (error) {
if (error !== "cancel" && error !== "close") {
ElMessage.error(error.message || t("mail.deleteFailed"));
}
}
}
async function downloadAttachment(attachment) {
const mailbox = selectedMailbox.value;
const message = messageDetail.value;
if (!mailbox || !message) return;
try {
const result = await mailboxRequest(
mailbox,
`/api/mailboxes/${mailbox.id}/emails/${message.id}/attachments/${attachment.id}`,
{ responseType: "blob" },
);
const url = URL.createObjectURL(result.blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = result.filename || attachment.filename || "attachment";
anchor.click();
URL.revokeObjectURL(url);
} catch (error) {
ElMessage.error(error.message || t("mail.downloadFailed"));
}
}
async function copyAddress(address) {
try {
await navigator.clipboard.writeText(address);
ElMessage.success(t("common.copied"));
} catch {
ElMessage.error(t("common.copyFailed"));
}
}
async function enableNotifications() {
if (typeof Notification === "undefined") {
ElMessage.warning(t("mail.notificationsUnsupported"));
return;
}
const permission = await Notification.requestPermission();
notificationEnabled.value = permission === "granted";
ElMessage[notificationEnabled.value ? "success" : "warning"](
notificationEnabled.value
? t("mail.notificationsEnabled")
: t("mail.notificationsDenied"),
);
}
function scheduleReconnect(mailbox) {
if (selectedMailboxId.value !== mailbox.id || reconnectTimer) return;
reconnectTimer = window.setTimeout(async () => {
reconnectTimer = null;
if (selectedMailboxId.value !== mailbox.id) return;
try {
await openSocket(mailbox);
} catch {
scheduleReconnect(mailbox);
}
}, 3000);
}
async function openSocket(mailbox) {
const token = await ensureMailboxToken(mailbox);
const url = websocketUrl(
`/api/mailboxes/${mailbox.id}/ws?token=${encodeURIComponent(token)}`,
);
socket = new WebSocket(url);
socket.onmessage = async (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.event && payload.event !== "new_email") return;
const incoming = payload.data || payload;
if (!incoming?.id) return;
if (!messages.value.some((message) => message.id === incoming.id)) {
messages.value.unshift(incoming);
}
ElMessage.success(t("mail.newMail"));
if (notificationEnabled.value && document.hidden) {
new Notification(incoming.subject || t("mail.noSubject"), {
body: incoming.mail_from,
});
}
await Promise.all([loadCredit(), loadMailboxes()]);
} catch (error) {
console.warn("Failed to handle mailbox event", error);
}
};
socket.onclose = () => {
scheduleReconnect(mailbox);
};
}
function closeSocket() {
if (reconnectTimer) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (socket) {
socket.onclose = null;
socket.close();
socket = null;
}
}
onMounted(async () => {
await nextTick();
await loadDashboard();
});
onUnmounted(closeSocket);
</script>
<template>
<main class="workspace-shell" :aria-busy="loading">
<section class="workspace-summary">
<div>
<p class="section-kicker">{{ t("workspace.account") }}</p>
<h1>{{ t("workspace.title") }}</h1>
</div>
<div class="summary-metrics">
<button class="metric-command" type="button" @click="showTransactions">
<el-icon><Wallet /></el-icon>
<span>
<small>{{ t("credits.balance") }}</small>
<strong>{{ credit.balance }}</strong>
</span>
</button>
<div class="metric">
<small>{{ t("credits.mailboxesToday") }}</small>
<strong>
{{ credit.usage_today.mailboxes_created }}/{{
credit.daily_limits.mailboxes
}}
</strong>
</div>
<div class="metric">
<small>{{ t("credits.emailsToday") }}</small>
<strong>
{{ credit.usage_today.emails_received }}/{{
credit.daily_limits.emails
}}
</strong>
</div>
</div>
<div class="summary-actions">
<button
class="icon-command"
type="button"
:title="t('mail.enableNotifications')"
:aria-label="t('mail.enableNotifications')"
@click="enableNotifications"
>
<el-icon
><BellFilled v-if="notificationEnabled" /><Bell v-else
/></el-icon>
</button>
<button
class="primary-command compact"
type="button"
:disabled="!canCreateMailbox"
@click="createDialogOpen = true"
>
<el-icon><Plus /></el-icon>
{{ t("mailbox.create") }}
</button>
<button
class="text-command check-in-command"
type="button"
:disabled="credit.check_in.claimed"
@click="checkIn"
>
<el-icon><CircleCheck /></el-icon>
{{
credit.check_in.claimed
? t("credits.checkedIn")
: t("credits.checkIn", { reward: credit.check_in.reward })
}}
</button>
</div>
</section>
<section v-if="loading" class="workspace-loading">
{{ t("common.loading") }}
</section>
<section v-else class="mail-workspace">
<aside class="mailbox-rail">
<div class="panel-heading">
<div>
<p class="section-kicker">{{ t("mailbox.label") }}</p>
<h2>{{ t("mailbox.yours") }}</h2>
</div>
<button
class="icon-command"
type="button"
:title="t('common.refresh')"
:aria-label="t('common.refresh')"
:disabled="mailboxLoading"
@click="loadMailboxes"
>
<el-icon><Refresh /></el-icon>
</button>
</div>
<div v-if="mailboxes.length === 0" class="empty-panel">
<el-icon><MessageBox /></el-icon>
<p>{{ t("mailbox.empty") }}</p>
<button
class="text-command"
type="button"
:disabled="!canCreateMailbox"
@click="createDialogOpen = true"
>
{{ t("mailbox.createFirst") }}
</button>
</div>
<div v-else class="mailbox-list">
<button
v-for="mailbox in mailboxes"
:key="mailbox.id"
class="mailbox-row"
:class="{ selected: mailbox.id === selectedMailboxId }"
type="button"
@click="selectMailbox(mailbox)"
>
<span class="status-dot" :class="mailbox.status"></span>
<span class="mailbox-copy">
<strong>{{ mailbox.local_part }}</strong>
<small>@{{ mailbox.domain }}</small>
<small>{{ formatDate(mailbox.expires_at) }}</small>
</span>
<span
class="row-delete"
role="button"
tabindex="0"
:title="t('mailbox.revoke')"
@click.stop="revokeMailbox(mailbox)"
@keydown.enter.stop="revokeMailbox(mailbox)"
>
<el-icon><Delete /></el-icon>
</span>
</button>
</div>
</aside>
<section class="message-index">
<div class="panel-heading">
<div>
<p class="section-kicker">
{{ selectedMailbox?.address || t("mailbox.none") }}
</p>
<h2>{{ t("mail.inbox") }}</h2>
</div>
<div class="panel-actions">
<button
v-if="selectedMailbox"
class="icon-command"
type="button"
:title="t('common.copy')"
:aria-label="t('common.copy')"
@click="copyAddress(selectedMailbox.address)"
>
<el-icon><CopyDocument /></el-icon>
</button>
<button
class="icon-command"
type="button"
:title="t('common.refresh')"
:aria-label="t('common.refresh')"
:disabled="!selectedMailbox || messageLoading"
@click="fetchMessages(true)"
>
<el-icon><Refresh /></el-icon>
</button>
</div>
</div>
<div v-if="selectedMailbox" class="quota-line">
<span>{{ formatBytes(selectedMailbox.used_bytes) }}</span>
<div><i :style="{ width: `${mailboxUsagePercent}%` }"></i></div>
<span>{{ formatBytes(selectedMailbox.max_quota_bytes) }}</span>
</div>
<p v-if="selectedMailbox" class="billing-note inbox-cost">
{{
t("credits.receiveCost", {
cost: credit.pricing.receive_email,
remaining: credit.remaining_today.emails,
})
}}
</p>
<div v-if="!selectedMailbox" class="empty-panel">
<p>{{ t("mailbox.selectPrompt") }}</p>
</div>
<div
v-else-if="messageLoading && messages.length === 0"
class="empty-panel"
>
<p>{{ t("common.loading") }}</p>
</div>
<div v-else-if="messages.length === 0" class="empty-panel">
<el-icon><MessageBox /></el-icon>
<p>{{ t("mail.empty") }}</p>
</div>
<div v-else class="message-list">
<button
v-for="message in messages"
:key="message.id"
class="message-row"
:class="{ selected: selectedMessage?.id === message.id }"
type="button"
@click="selectMessage(message)"
>
<span class="message-from">
{{ message.sender_name || message.mail_from }}
</span>
<strong>{{ message.subject || t("mail.noSubject") }}</strong>
<small>{{ formatDate(message.received_at) }}</small>
</button>
<button
v-if="hasMoreMessages"
class="load-more"
type="button"
:disabled="messageLoading"
@click="fetchMessages(false)"
>
{{ t("common.loadMore") }}
</button>
</div>
</section>
<article class="message-inspector">
<div v-if="!messageDetail && !detailLoading" class="empty-panel">
<el-icon><Tickets /></el-icon>
<p>{{ t("mail.selectPrompt") }}</p>
</div>
<div v-else-if="detailLoading" class="empty-panel">
<p>{{ t("common.loading") }}</p>
</div>
<template v-else>
<header class="message-header">
<div>
<p class="section-kicker">
{{ formatDate(messageDetail.received_at) }}
</p>
<h2>{{ messageDetail.subject || t("mail.noSubject") }}</h2>
<p>
{{ messageDetail.sender_name || messageDetail.mail_from }}
<span>&lt;{{ messageDetail.mail_from }}&gt;</span>
</p>
</div>
<button
class="icon-command danger"
type="button"
:title="t('mail.delete')"
:aria-label="t('mail.delete')"
@click="deleteMessage"
>
<el-icon><Delete /></el-icon>
</button>
</header>
<p v-if="messageDetail.html_body" class="privacy-note">
{{ t("mail.remoteImagesBlocked") }}
</p>
<div class="message-body" v-html="sanitizedBody"></div>
<section
v-if="messageDetail.attachments?.length"
class="attachment-list"
>
<h3>{{ t("mail.attachments") }}</h3>
<button
v-for="attachment in messageDetail.attachments"
:key="attachment.id"
type="button"
@click="downloadAttachment(attachment)"
>
<span>
<strong>{{
attachment.filename || t("mail.unnamedAttachment")
}}</strong>
<small
>{{ attachment.content_type }} ·
{{ formatBytes(attachment.size_bytes) }}</small
>
</span>
<el-icon><Download /></el-icon>
</button>
</section>
</template>
</article>
</section>
<el-dialog
v-model="createDialogOpen"
:title="t('mailbox.create')"
width="min(92vw, 480px)"
>
<form class="dialog-form" @submit.prevent="createMailbox">
<label>
<span>{{ t("mailbox.localPart") }}</span>
<div class="address-field">
<input
v-model.trim="createForm.local_part"
maxlength="64"
pattern="[a-zA-Z0-9._-]+"
:placeholder="t('mailbox.randomPlaceholder')"
/>
<span v-if="configuredDomain">@{{ configuredDomain }}</span>
</div>
</label>
<label>
<span>{{ t("mailbox.note") }}</span>
<input
v-model.trim="createForm.note"
maxlength="128"
:placeholder="t('mailbox.notePlaceholder')"
/>
</label>
<label>
<span>{{ t("mailbox.ttl") }}</span>
<el-input-number v-model="createForm.ttl_hours" :min="1" :max="168" />
</label>
<p class="billing-note">
{{ t("mailbox.costNotice", { cost: credit.pricing.create_mailbox }) }}
</p>
<button
class="primary-command"
type="submit"
:disabled="!canCreateMailbox"
>
<el-icon><Plus /></el-icon>
{{ t("mailbox.create") }}
</button>
</form>
</el-dialog>
<el-drawer
v-model="creditsDrawerOpen"
:title="t('credits.history')"
size="min(92vw, 440px)"
>
<div class="credit-overview">
<div>
<small>{{ t("credits.balance") }}</small>
<strong>{{ credit.balance }}</strong>
</div>
<p>
{{
t("credits.summary", {
granted: credit.total_granted,
consumed: credit.total_consumed,
})
}}
</p>
</div>
<div v-if="transactions.length === 0" class="empty-panel">
<p>{{ t("credits.empty") }}</p>
</div>
<ol v-else class="transaction-list">
<li v-for="transaction in transactions" :key="transaction.id">
<span>
<strong>{{ transactionReason(transaction.reason) }}</strong>
<small v-if="transaction.description">{{
transaction.description
}}</small>
<small>{{ formatDate(transaction.created_at) }}</small>
</span>
<span :class="transaction.delta >= 0 ? 'positive' : 'negative'">
{{ transaction.delta >= 0 ? "+" : "" }}{{ transaction.delta }}
<small>{{
t("credits.balanceAfter", { balance: transaction.balance_after })
}}</small>
</span>
</li>
</ol>
</el-drawer>
</main>
</template>
+266 -90
View File
@@ -1,103 +1,279 @@
import { createI18n } from 'vue-i18n';
import { createI18n } from "vue-i18n";
const messages = {
zh: {
howItWorks: {
title: '工作原理',
step1: '在上面的输入框中,随意编造一个以 {domain} 结尾的邮箱地址。',
step2: '使用这个地址去注册任何网站或接收邮件。',
step3: '在这里输入您刚刚使用的地址,点击“查看收件箱”,即可看到邮件。',
common: {
loading: "正在加载",
processing: "处理中…",
refresh: "刷新",
loadMore: "加载更多",
copy: "复制",
copied: "已复制",
copyFailed: "复制失败",
requestFailed: "请求失败,请稍后重试",
},
language: '语言',
home: {
title: '您的专属临时邮箱,无限且私密',
subtitle: '输入任何',
subtitleAfter: '地址,立即在此查看收件箱。',
placeholder: '输入您的临时邮箱地址...',
copyTitle: '复制地址',
random: '随机生成',
checkInbox: '查看收件箱',
inbox: '收件箱',
refresh: '刷新',
loading: '正在加载...',
noMail: '暂无邮件',
selectMail: '请从选择一封邮件查看',
from: '发件人',
to: '收件人',
received_at: '收件时间',
attachments: '附件',
delete: '删除',
clearInbox: '清空收件箱',
alerts: {
enterEmail: '请输入一个邮箱地址',
fetchFailed: '无法获取邮件,请检查后端服务和 Nginx 配置是否正常。',
confirmDelete: '确定要删除这封邮件吗?',
deleteFailed: '删除邮件失败。',
confirmClearAll: '确定要清空这个收件箱的所有邮件吗?此操作不可恢复。',
clearFailed: '清空收件箱失败。',
generateRandomSuccess: '已生成随机邮箱地址',
checkInboxStart: '正在查询收件箱',
copyFailed: '复制失败!',
copySuccess: '复制成功!', // <-- 这里添加
actionCancelled: '操作已取消',
deleteSuccess: '删除成功!',
clearSuccess: '收件箱已清空!',
header: {
productLabel: "临时邮箱工作台",
navigation: "主导航",
settings: "设置",
},
auth: {
kicker: "私密、限时、可控",
description:
"创建真实存在的临时邮箱,实时收取邮件,并由独立访问令牌保护每个收件箱。",
featureMailbox: "邮箱地址必须由您主动创建",
featureRealtime: "新邮件通过 WebSocket 实时到达",
featurePrivacy: "正文净化显示,默认阻止远程追踪图片",
login: "登录",
register: "注册",
logout: "退出登录",
welcomeBack: "继续管理您的邮箱",
createAccount: "创建用户账户",
loginHint: "登录后可恢复邮箱列表与积分记录。",
registerHint: "注册成功会自动登录并获得初始积分。",
email: "邮箱",
password: "密码",
inviteCode: "邀请码",
emailPlaceholder: "you{'@'}example.com",
passwordPlaceholder: "至少 8 个字符",
invitePlaceholder: "未启用时可留空",
loginSuccess: "登录成功",
registerSuccess: "注册成功",
},
workspace: {
account: "用户工作台",
title: "邮箱与收件箱",
},
mailbox: {
label: "邮箱",
yours: "我的邮箱",
none: "未选择邮箱",
empty: "还没有临时邮箱",
create: "创建邮箱",
createFirst: "创建第一个邮箱",
created: "邮箱创建成功",
createFailed: "邮箱创建失败",
selectPrompt: "选择一个有效邮箱查看邮件",
localPart: "邮箱前缀",
randomPlaceholder: "留空则随机生成",
note: "用途备注",
notePlaceholder: "例如:注册测试账号",
ttl: "有效期(小时)",
costNotice: "本次创建将消耗 {cost} 积分,并计入今日创建额度。",
revoke: "吊销邮箱",
revokeConfirm:
"确定吊销 {address}?吊销后将停止收信,且原 token 立即失效。",
revoked: "邮箱已吊销",
revokeFailed: "邮箱吊销失败",
status: {
active: "使用中",
expired: "邮箱已过期",
revoked: "邮箱已吊销",
},
newMailNotification: '收到新邮件!',
}
},
mail: {
inbox: "收件箱",
empty: "尚未收到邮件",
selectPrompt: "选择一封邮件查看正文",
noSubject: "无主题",
noBody: "这封邮件没有可显示的正文。",
fetchFailed: "获取邮件列表失败",
fetchDetailFailed: "获取邮件详情失败",
delete: "删除邮件",
deleteConfirm: "确定永久删除这封邮件?邮箱占用容量会同步释放。",
deleted: "邮件已删除",
deleteFailed: "删除邮件失败",
attachments: "附件",
unnamedAttachment: "未命名附件",
downloadFailed: "附件下载失败",
newMail: "收到一封新邮件",
enableNotifications: "启用浏览器通知",
notificationsEnabled: "浏览器通知已启用",
notificationsDenied: "未获得通知权限",
notificationsUnsupported: "当前浏览器不支持通知",
remoteImagesBlocked: "为保护隐私,邮件中的远程图片和嵌入内容已被阻止。",
},
credits: {
balance: "积分余额",
mailboxesToday: "今日邮箱",
emailsToday: "今日收信",
history: "积分流水",
empty: "暂无积分流水",
summary: "累计获得 {granted},累计消费 {consumed}",
balanceAfter: "余额 {balance}",
checkIn: "签到 +{reward}",
checkedIn: "今日已签到",
checkInSuccess: "签到成功,获得 {reward} 积分",
checkInFailed: "签到失败",
receiveCost: "每封邮件消耗 {cost} 积分,今日还可接收 {remaining} 封。",
reasons: {
register_bonus: "注册赠送",
daily_check_in: "每日签到",
create_mailbox: "创建邮箱",
receive_email: "接收邮件",
admin_adjust: "管理员调整",
migration_bonus: "存量账户初始化",
},
},
settings: {
kicker: "偏好与安全",
title: "设置",
language: "界面语言",
languageHint: "语言设置会保存在当前浏览器。",
serviceStatus: "服务能力",
serviceHint: "显示当前后端实际启用的依赖能力。",
database: "数据库",
outboundEmail: "外发邮件",
deleteAccount: "删除账户",
deleteHint: "账户删除后,全部临时邮箱会立即吊销且无法继续收信。",
deleteConfirm: "此操作不可恢复。确认删除账户?",
confirmDelete: "永久删除",
deleted: "账户已删除",
deleteFailed: "账户删除失败",
},
},
en: {
howItWorks: {
title: 'How it Works',
step1: 'Invent any email address ending in {domain} in the input box above.',
step2: 'Use this address to register on any website or receive emails.',
step3: 'Enter the address you just used here and click "Check Inbox" to see the emails.',
common: {
loading: "Loading",
processing: "Processing…",
refresh: "Refresh",
loadMore: "Load more",
copy: "Copy",
copied: "Copied",
copyFailed: "Copy failed",
requestFailed: "Request failed. Please try again.",
},
language: 'Language',
home: {
title: 'Your Exclusive, Unlimited, and Private Temporary Email',
subtitle: 'Enter any address ending in ',
subtitleAfter: ' to check the inbox right here.',
placeholder: 'Enter your temporary email address...',
copyTitle: 'Copy Address',
random: 'Generate Random',
checkInbox: 'Check Inbox',
inbox: 'Inbox',
refresh: 'Refresh',
loading: 'Loading...',
noMail: 'No mail yet',
selectMail: 'Please select an email to view',
from: 'From',
to: 'To',
received_at: 'Received At',
attachments: 'Attachments',
delete: 'Delete',
clearInbox: 'Clear Inbox',
alerts: {
enterEmail: 'Please enter an email address',
fetchFailed: 'Failed to fetch emails. Please check if the backend service and Nginx configuration are normal.',
confirmDelete: 'Are you sure you want to delete this email?',
deleteFailed: 'Failed to delete email.',
confirmClearAll: 'Are you sure you want to clear all emails in this inbox? This action cannot be undone.',
clearFailed: 'Failed to clear inbox.',
generateRandomSuccess: 'Random email address generated',
checkInboxStart: 'Checking inbox',
copyFailed: 'Copy failed!',
copySuccess: 'Copied successfully!', // <-- 这里添加
actionCancelled: 'Action cancelled',
deleteSuccess: 'Deleted successfully!',
clearSuccess: 'Inbox cleared!',
header: {
productLabel: "Temporary mail workspace",
navigation: "Main navigation",
settings: "Settings",
},
auth: {
kicker: "Private, temporary, controlled",
description:
"Create real temporary addresses, receive mail live, and protect every inbox with its own access token.",
featureMailbox: "Every address must be explicitly created by you",
featureRealtime: "New mail arrives live over WebSocket",
featurePrivacy: "Sanitized content with remote tracking images blocked",
login: "Sign in",
register: "Register",
logout: "Sign out",
welcomeBack: "Continue managing your mailboxes",
createAccount: "Create your account",
loginHint: "Sign in to restore mailboxes and credit history.",
registerHint: "Registration signs you in and grants starting credits.",
email: "Email",
password: "Password",
inviteCode: "Invite code",
emailPlaceholder: "you{'@'}example.com",
passwordPlaceholder: "At least 8 characters",
invitePlaceholder: "Leave blank when not required",
loginSuccess: "Signed in",
registerSuccess: "Account created",
},
workspace: {
account: "User workspace",
title: "Mailboxes and inbox",
},
mailbox: {
label: "Mailboxes",
yours: "My mailboxes",
none: "No mailbox selected",
empty: "No temporary mailboxes yet",
create: "Create mailbox",
createFirst: "Create your first mailbox",
created: "Mailbox created",
createFailed: "Could not create mailbox",
selectPrompt: "Select an active mailbox to view its mail",
localPart: "Address prefix",
randomPlaceholder: "Leave blank for a random address",
note: "Purpose note",
notePlaceholder: "For example: test account",
ttl: "Lifetime (hours)",
costNotice:
"Creating this mailbox costs {cost} credits and uses todays creation quota.",
revoke: "Revoke mailbox",
revokeConfirm:
"Revoke {address}? It will stop receiving mail and its current token will become invalid.",
revoked: "Mailbox revoked",
revokeFailed: "Could not revoke mailbox",
status: {
active: "Active",
expired: "Mailbox expired",
revoked: "Mailbox revoked",
},
newMailNotification: 'New mail received!',
}
},
mail: {
inbox: "Inbox",
empty: "No mail yet",
selectPrompt: "Select a message to inspect it",
noSubject: "No subject",
noBody: "This message has no displayable body.",
fetchFailed: "Could not load messages",
fetchDetailFailed: "Could not load message details",
delete: "Delete message",
deleteConfirm:
"Permanently delete this message? Its storage usage will be released.",
deleted: "Message deleted",
deleteFailed: "Could not delete message",
attachments: "Attachments",
unnamedAttachment: "Unnamed attachment",
downloadFailed: "Attachment download failed",
newMail: "New message received",
enableNotifications: "Enable browser notifications",
notificationsEnabled: "Browser notifications enabled",
notificationsDenied: "Notification permission was not granted",
notificationsUnsupported:
"Notifications are not supported by this browser",
remoteImagesBlocked:
"Remote images and embedded content were blocked to protect your privacy.",
},
credits: {
balance: "Credit balance",
mailboxesToday: "Mailboxes today",
emailsToday: "Mail today",
history: "Credit history",
empty: "No credit activity yet",
summary: "{granted} granted, {consumed} consumed",
balanceAfter: "Balance {balance}",
checkIn: "Check in +{reward}",
checkedIn: "Checked in today",
checkInSuccess: "Checked in. {reward} credits granted",
checkInFailed: "Check-in failed",
receiveCost:
"Each message costs {cost} credit(s). {remaining} remaining today.",
reasons: {
register_bonus: "Registration bonus",
daily_check_in: "Daily check-in",
create_mailbox: "Mailbox created",
receive_email: "Message received",
admin_adjust: "Admin adjustment",
migration_bonus: "Existing account initialization",
},
},
settings: {
kicker: "Preferences and security",
title: "Settings",
language: "Interface language",
languageHint: "Your language choice is saved in this browser.",
serviceStatus: "Service capabilities",
serviceHint: "Capabilities currently enabled by the backend.",
database: "Database",
outboundEmail: "Outbound email",
deleteAccount: "Delete account",
deleteHint:
"Deleting your account immediately revokes every temporary mailbox.",
deleteConfirm: "This cannot be undone. Delete your account?",
confirmDelete: "Delete permanently",
deleted: "Account deleted",
deleteFailed: "Could not delete account",
},
},
};
const i18n = createI18n({
legacy: false, // 使用组合式 API
locale: 'zh', // 设置默认语言
fallbackLocale: 'en', // 设置回退语言
messages, // 设置语言环境信息
export default createI18n({
legacy: false,
locale: "zh",
fallbackLocale: "en",
messages,
});
export default i18n;
+135
View File
@@ -0,0 +1,135 @@
const API_BASE = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
const SESSION_KEY = "email-unlimit-session";
export class ApiError extends Error {
constructor(message, status = 0) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function readSession() {
try {
return JSON.parse(localStorage.getItem(SESSION_KEY)) || null;
} catch {
return null;
}
}
export function writeSession(session) {
if (session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
} else {
localStorage.removeItem(SESSION_KEY);
}
}
let refreshPromise = null;
async function refreshSession() {
const session = readSession();
if (!session?.refresh_token) return null;
if (!refreshPromise) {
refreshPromise = fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: session.refresh_token }),
})
.then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.data) {
writeSession(null);
return null;
}
const nextSession = { ...session, ...payload.data };
writeSession(nextSession);
return nextSession;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
function responseFilename(response) {
const disposition = response.headers.get("content-disposition") || "";
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
const encoded = utf8Match?.[1] || plainMatch?.[1];
if (!encoded) return null;
try {
return decodeURIComponent(encoded);
} catch {
return encoded;
}
}
export async function apiRequest(path, options = {}) {
const {
auth = "user",
mailboxToken,
body,
responseType = "json",
retry = true,
headers: customHeaders,
...fetchOptions
} = options;
const headers = new Headers(customHeaders || {});
if (body !== undefined && !(body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (auth === "user") {
const token = readSession()?.access_token;
if (token) headers.set("Authorization", `Bearer ${token}`);
} else if (auth === "mailbox" && mailboxToken) {
headers.set("Authorization", `Bearer ${mailboxToken}`);
}
const response = await fetch(`${API_BASE}${path}`, {
...fetchOptions,
headers,
body:
body === undefined || body instanceof FormData
? body
: JSON.stringify(body),
});
if (response.status === 401 && auth === "user" && retry) {
const refreshed = await refreshSession();
if (refreshed) {
return apiRequest(path, { ...options, retry: false });
}
}
if (responseType === "blob") {
if (!response.ok) {
throw new ApiError("附件下载失败", response.status);
}
return {
blob: await response.blob(),
filename: responseFilename(response),
};
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new ApiError(
payload?.message || `请求失败 (${response.status})`,
response.status,
);
}
return payload && Object.prototype.hasOwnProperty.call(payload, "data")
? payload.data
: payload;
}
export function websocketUrl(path) {
const url = new URL(`${API_BASE}${path}`, window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
+27
View File
@@ -0,0 +1,27 @@
function storageKey(userEmail) {
return `email-unlimit-mailbox-tokens:${userEmail || "anonymous"}`;
}
function readTokens(userEmail) {
try {
return JSON.parse(localStorage.getItem(storageKey(userEmail))) || {};
} catch {
return {};
}
}
export function getMailboxToken(userEmail, mailboxId) {
return readTokens(userEmail)[mailboxId] || null;
}
export function setMailboxToken(userEmail, mailboxId, token) {
const tokens = readTokens(userEmail);
tokens[mailboxId] = token;
localStorage.setItem(storageKey(userEmail), JSON.stringify(tokens));
}
export function removeMailboxToken(userEmail, mailboxId) {
const tokens = readTokens(userEmail);
delete tokens[mailboxId];
localStorage.setItem(storageKey(userEmail), JSON.stringify(tokens));
}
+24 -21
View File
@@ -1,32 +1,35 @@
import { createApp, watch } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import { useLanguageStore } from './stores/language'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './assets/main.css'
import { createApp, watch } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import i18n from "./i18n";
import { useLanguageStore } from "./stores/language";
import { ElDialog, ElDrawer, ElIcon, ElInputNumber } from "element-plus";
import "element-plus/dist/index.css";
import "./assets/main.css";
const app = createApp(App)
const pinia = createPinia()
const app = createApp(App);
const pinia = createPinia();
app.use(pinia)
app.use(router)
app.use(i18n)
app.use(ElementPlus)
app.use(pinia);
app.use(router);
app.use(i18n);
app.component("ElDialog", ElDialog);
app.component("ElDrawer", ElDrawer);
app.component("ElIcon", ElIcon);
app.component("ElInputNumber", ElInputNumber);
const languageStore = useLanguageStore(pinia)
const languageStore = useLanguageStore(pinia);
// 监听 Pinia store 中的 locale 变化,并更新 i18n
watch(
() => languageStore.locale,
(newLocale) => {
i18n.global.locale.value = newLocale
}
)
i18n.global.locale.value = newLocale;
},
);
// 初始化时,确保 i18n 的 locale 与 store 同步
i18n.global.locale.value = languageStore.locale
i18n.global.locale.value = languageStore.locale;
app.mount('#app')
app.mount("#app");
+8 -9
View File
@@ -1,22 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue';
import Settings from '../views/Settings.vue';
import { createRouter, createWebHistory } from "vue-router";
import Home from "../views/Home.vue";
const routes = [
{
path: '/',
name: 'Home',
path: "/",
name: "Home",
component: Home,
},
{
path: '/settings',
name: 'Settings',
component: Settings,
path: "/settings",
name: "Settings",
component: () => import("../views/Settings.vue"),
},
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
+73
View File
@@ -0,0 +1,73 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { apiRequest, readSession, writeSession } from "@/lib/api";
export const useAuthStore = defineStore("auth", () => {
const session = ref(readSession());
const loading = ref(false);
const isAuthenticated = computed(() => Boolean(session.value?.access_token));
const email = computed(() => session.value?.email || "");
function setSession(value) {
session.value = value;
writeSession(value);
}
async function login(credentials) {
loading.value = true;
try {
const result = await apiRequest("/auth/login", {
method: "POST",
auth: false,
body: credentials,
});
setSession(result);
return result;
} finally {
loading.value = false;
}
}
async function register(credentials) {
loading.value = true;
try {
const result = await apiRequest("/auth/register", {
method: "POST",
auth: false,
body: credentials,
});
setSession({ role: "user", status: "active", ...result });
return result;
} finally {
loading.value = false;
}
}
async function logout() {
try {
if (session.value?.access_token) {
await apiRequest("/auth/logout", { method: "POST" });
}
} catch {
// Local logout must still succeed when Redis is unavailable.
} finally {
setSession(null);
}
}
function syncSession() {
session.value = readSession();
}
return {
session,
loading,
isAuthenticated,
email,
login,
register,
logout,
syncSession,
};
});
+4 -4
View File
@@ -1,11 +1,11 @@
import { defineStore } from 'pinia';
import { defineStore } from "pinia";
const STORAGE_KEY = 'user_language';
const STORAGE_KEY = "user_language";
export const useLanguageStore = defineStore('language', {
export const useLanguageStore = defineStore("language", {
state: () => ({
// 优先从 localStorage 获取,否则默认为中文
locale: localStorage.getItem(STORAGE_KEY) || 'zh',
locale: localStorage.getItem(STORAGE_KEY) || "zh",
}),
actions: {
setLocale(newLocale) {
-11
View File
@@ -1,11 +0,0 @@
import { md5 } from './md5.js';
function get_gravatar(email, size = 80, defaultImage = 'retro') {
if (!email) {
return `https://www.gravatar.com/avatar/?s=${size}&d=${defaultImage}`;
}
const emailHash = md5(email.trim().toLowerCase());
return `https://www.gravatar.com/avatar/${emailHash}?s=${size}&d=${defaultImage}`;
}
export { get_gravatar };
-219
View File
@@ -1,219 +0,0 @@
/*
* A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
* Digest Algorithm, as defined in RFC 1321.
* Version 2.2 Copyright (C) Paul Johnston 1999 - 2009
* Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
* Distributed under the BSD License
* See http://pajhome.org.uk/crypt/md5 for more info.
*/
function md5(string) {
function RotateLeft(lValue, iShiftBits) {
return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
}
function AddUnsigned(lX, lY) {
var lX4, lY4, lX8, lY8, lResult;
lX8 = (lX & 0x80000000);
lY8 = (lY & 0x80000000);
lX4 = (lX & 0x40000000);
lY4 = (lY & 0x40000000);
lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF);
if (lX4 & lY4) {
return (lResult ^ 0x80000000 ^ lX8 ^ lY8);
}
if (lX4 | lY4) {
if (lResult & 0x40000000) {
return (lResult ^ 0xC0000000 ^ lX8 ^ lY8);
} else {
return (lResult ^ 0x40000000 ^ lX8 ^ lY8);
}
} else {
return (lResult ^ lX8 ^ lY8);
}
}
function F(x, y, z) {
return (x & y) | ((~x) & z);
}
function G(x, y, z) {
return (x & z) | (y & (~z));
}
function H(x, y, z) {
return (x ^ y ^ z);
}
function I(x, y, z) {
return (y ^ (x | (~z)));
}
function FF(a, b, c, d, x, s, ac) {
a = AddUnsigned(a, AddUnsigned(AddUnsigned(F(b, c, d), x), ac));
return AddUnsigned(RotateLeft(a, s), b);
}
function GG(a, b, c, d, x, s, ac) {
a = AddUnsigned(a, AddUnsigned(AddUnsigned(G(b, c, d), x), ac));
return AddUnsigned(RotateLeft(a, s), b);
}
function HH(a, b, c, d, x, s, ac) {
a = AddUnsigned(a, AddUnsigned(AddUnsigned(H(b, c, d), x), ac));
return AddUnsigned(RotateLeft(a, s), b);
}
function II(a, b, c, d, x, s, ac) {
a = AddUnsigned(a, AddUnsigned(AddUnsigned(I(b, c, d), x), ac));
return AddUnsigned(RotateLeft(a, s), b);
}
function ConvertToWordArray(string) {
var lWordCount;
var lMessageLength = string.length;
var lNumberOfWords_temp1 = lMessageLength + 8;
var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
var lWordArray = Array(lNumberOfWords - 1);
var lBytePosition = 0;
var lByteCount = 0;
while (lByteCount < lMessageLength) {
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = (lWordArray[lWordCount] | (string.charCodeAt(lByteCount) << lBytePosition));
lByteCount++;
}
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
return lWordArray;
}
function WordToHex(lValue) {
var WordToHexValue = "", WordToHexValue_temp = "", lByte, lCount;
for (lCount = 0; lCount <= 3; lCount++) {
lByte = (lValue >>> (lCount * 8)) & 255;
WordToHexValue_temp = "0" + lByte.toString(16);
WordToHexValue = WordToHexValue + WordToHexValue_temp.substr(WordToHexValue_temp.length - 2, 2);
}
return WordToHexValue;
}
function Utf8Encode(string) {
string = string.replace(/\r\n/g, "\n");
var utftext = "";
for (var n = 0; n < string.length; n++) {
var c = string.charCodeAt(n);
if (c < 128) {
utftext += String.fromCharCode(c);
}
else if ((c > 127) && (c < 2048)) {
utftext += String.fromCharCode((c >> 6) | 192);
utftext += String.fromCharCode((c & 63) | 128);
}
else {
utftext += String.fromCharCode((c >> 12) | 224);
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
utftext += String.fromCharCode((c & 63) | 128);
}
}
return utftext;
}
var x = Array();
var k, AA, BB, CC, DD, a, b, c, d;
var S11 = 7, S12 = 12, S13 = 17, S14 = 22;
var S21 = 5, S22 = 9, S23 = 14, S24 = 20;
var S31 = 4, S32 = 11, S33 = 16, S34 = 23;
var S41 = 6, S42 = 10, S43 = 15, S44 = 21;
string = Utf8Encode(string);
x = ConvertToWordArray(string);
a = 0x67452301; b = 0xEFCDAB89; c = 0x98BADCFE; d = 0x10325476;
for (k = 0; k < x.length; k += 16) {
AA = a; BB = b; CC = c; DD = d;
a = FF(a, b, c, d, x[k + 0], S11, 0xD76AA478);
d = FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756);
c = FF(c, d, a, b, x[k + 2], S13, 0x242070DB);
b = FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE);
a = FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF);
d = FF(d, a, b, c, x[k + 5], S12, 0x4787C62A);
c = FF(c, d, a, b, x[k + 6], S13, 0xA8304613);
b = FF(b, c, d, a, x[k + 7], S14, 0xFD469501);
a = FF(a, b, c, d, x[k + 8], S11, 0x698098D8);
d = FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF);
c = FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1);
b = FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE);
a = FF(a, b, c, d, x[k + 12], S11, 0x6B901122);
d = FF(d, a, b, c, x[k + 13], S12, 0xFD987193);
c = FF(c, d, a, b, x[k + 14], S13, 0xA679438E);
b = FF(b, c, d, a, x[k + 15], S14, 0x49B40821);
a = GG(a, b, c, d, x[k + 1], S21, 0xF61E2562);
d = GG(d, a, b, c, x[k + 6], S22, 0xC040B340);
c = GG(c, d, a, b, x[k + 11], S23, 0x265E5A51);
b = GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA);
a = GG(a, b, c, d, x[k + 5], S21, 0xD62F105D);
d = GG(d, a, b, c, x[k + 10], S22, 0x2441453);
c = GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681);
b = GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8);
a = GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6);
d = GG(d, a, b, c, x[k + 14], S22, 0xC33707D6);
c = GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87);
b = GG(b, c, d, a, x[k + 8], S24, 0x455A14ED);
a = GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905);
d = GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8);
c = GG(c, d, a, b, x[k + 7], S23, 0x676F02D9);
b = GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A);
a = HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942);
d = HH(d, a, b, c, x[k + 8], S32, 0x8771F681);
c = HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122);
b = HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C);
a = HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44);
d = HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9);
c = HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60);
b = HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70);
a = HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6);
d = HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA);
c = HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085);
b = HH(b, c, d, a, x[k + 6], S34, 0x4881D05);
a = HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039);
d = HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5);
c = HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8);
b = HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665);
a = II(a, b, c, d, x[k + 0], S41, 0xF4292244);
d = II(d, a, b, c, x[k + 7], S42, 0x432AFF97);
c = II(c, d, a, b, x[k + 14], S43, 0xAB9423A7);
b = II(b, c, d, a, x[k + 5], S44, 0xFC93A039);
a = II(a, b, c, d, x[k + 12], S41, 0x655B59C3);
d = II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92);
c = II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D);
b = II(b, c, d, a, x[k + 1], S44, 0x85845DD1);
a = II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F);
d = II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0);
c = II(c, d, a, b, x[k + 6], S43, 0xA3014314);
b = II(b, c, d, a, x[k + 13], S44, 0x4E0811A1);
a = II(a, b, c, d, x[k + 4], S41, 0xF7537E82);
d = II(d, a, b, c, x[k + 11], S42, 0xBD3AF235);
c = II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB);
b = II(b, c, d, a, x[k + 9], S44, 0xEB86D391);
a = AddUnsigned(a, AA);
b = AddUnsigned(b, BB);
c = AddUnsigned(c, CC);
d = AddUnsigned(d, DD);
}
var temp = WordToHex(a) + WordToHex(b) + WordToHex(c) + WordToHex(d);
return temp.toLowerCase();
}
export { md5 };
+8 -433
View File
@@ -1,437 +1,12 @@
<template>
<main id="app-main">
<section class="hero-section">
<h1>{{ $t('home.title') }}</h1>
<p>{{ $t('home.subtitle') }}<code>@{{ domain }}</code>{{ $t('home.subtitleAfter') }}</p>
<form @submit.prevent="fetchMessages" class="input-group">
<div class="input-wrapper">
<input
type="email"
v-model="recipient"
class="email-input"
:placeholder="$t('home.placeholder')"
required
/>
<button @click="copyEmail" type="button" class="btn-copy" :title="$t('home.copyTitle')">
<el-icon v-if="copyStatus === 'copied'"><Check /></el-icon>
<el-icon v-else><CopyDocument /></el-icon>
</button>
</div>
<button @click="generateRandomEmail" type="button" class="btn btn-secondary">{{ $t('home.random') }}</button>
<button type="submit" class="btn btn-primary">{{ $t('home.checkInbox') }}</button>
</form>
</section>
<section class="inbox-section">
<div class="inbox-container">
<div class="message-list">
<div class="message-list-header">
<h2>{{ $t('home.inbox') }}</h2>
<div class="header-actions">
<button @click="clearInbox" :title="$t('home.clearInbox')" :disabled="messages.length === 0">
<el-icon><Delete /></el-icon>
</button>
<button @click="fetchMessages" :title="$t('home.refresh')">
<el-icon><Refresh /></el-icon>
</button>
</div>
</div>
<div v-if="newMailNotification" class="new-mail-notification">
{{ $t('home.newMailNotification') }}
</div>
<div v-if="loading" class="loading-state">{{ $t('home.loading') }}</div>
<div v-else-if="messages.length === 0" class="empty-state">{{ $t('home.noMail') }}</div>
<div v-else>
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ selected: selectedMessage && selectedMessage.id === message.id }"
@click="selectMessage(message)"
>
<div class="from">{{ message.sender }}</div>
<div class="subject">{{ message.subject }}</div>
</div>
</div>
</div>
<div class="message-detail">
<div v-if="!selectedMessage" class="empty-state">
<p>{{ $t('home.selectMail') }}</p>
</div>
<div v-else>
<div class="message-content-header">
<div class="header-text">
<h3>{{ selectedMessage.subject }}</h3>
<p class="from-line"><strong>{{ $t('home.from') }}:</strong> {{ selectedMessage.sender }}</p>
<p><strong>{{ $t('home.to') }}:</strong> {{ selectedMessage.recipient }}</p>
<p><strong>{{ $t('home.received_at') }}:</strong> {{ selectedMessage.received_at }}</p>
</div>
<div class="header-actions">
<button @click="deleteMessage(selectedMessage.id)" :title="$t('home.delete')">
<el-icon><Delete /></el-icon>
</button>
</div>
</div>
<div class="message-body" v-html="renderedBody">
</div>
<div v-if="attachmentsLoading" class="loading-state">{{ $t('home.loading') }}</div>
<div v-if="attachments.length > 0" class="attachments-section">
<h4>{{ $t('home.attachments') }}</h4>
<ul>
<li v-for="attachment in attachments" :key="attachment.id">
<a :href="`/api/attachments/${attachment.id}`" :download="attachment.filename">{{ attachment.filename }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup>
import { ref, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Delete, Refresh, CopyDocument, Check } from '@element-plus/icons-vue';
import { marked } from 'marked';
const { t } = useI18n();
const recipient = ref('');
const messages = ref([]);
const selectedMessage = ref(null);
const domain = import.meta.env.VITE_APP_DOMAIN;
if (import.meta.env.DEV) {
selectedMessage.value = {
id: 'sample-1',
sender: 'demo@example.com',
recipient: `you@${domain}`,
subject: 'Markdown 样式测试邮件',
body: `# 会议议程:项目启动会\n\n大家好,\n\n这是关于 **“Email-Unlimit”** 项目启动会的议程安排。\n\n---\n\n## 会议详情\n\n- **日期**: 2025年7月30日\n- **时间**: 上午10:00\n- **地点**: 线上会议室 (链接稍后提供)\n\n## 议程\n\n1. **项目介绍** - 介绍项目目标和范围。\n2. **团队分工** - 明确各自的职责。\n3. **技术选型** - 讨论并确认技术栈。\n4. **Q&A** - 自由提问环节。\n\n请准时参加。\n\n谢谢!\n\n> 这是一条重要的提醒:请提前准备好您的问题。`
};
}
const loading = ref(false);
const copyStatus = ref('idle'); // 'idle' | 'copied'
const attachments = ref([]);
const attachmentsLoading = ref(false);
const newMailNotification = ref(false);
const renderedBody = computed(() => {
if (selectedMessage.value && selectedMessage.value.body) {
return marked(selectedMessage.value.body);
}
return '';
});
let ws = null;
const setupWebSocket = () => {
if (ws) {
ws.close();
}
if (!recipient.value) return;
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws?recipient=${encodeURIComponent(recipient.value)}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connection established');
};
ws.onmessage = (event) => {
const newEmail = JSON.parse(event.data);
messages.value.unshift(newEmail);
ElMessage.success(t('home.newMailNotification'));
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket connection closed');
};
};
onUnmounted(() => {
if (ws) {
ws.close();
}
});
const fetchMessages = async () => {
if (!recipient.value) {
ElMessage.warning(t('home.alerts.enterEmail'));
return;
}
ElMessage.info(t('home.alerts.checkInboxStart'));
loading.value = true;
selectedMessage.value = null;
attachments.value = [];
try {
const response = await fetch(`/api/messages?recipient=${recipient.value}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
messages.value = data;
if (messages.value.length > 0) {
selectMessage(messages.value[0]);
}
setupWebSocket(); // Setup WebSocket after fetching messages
} catch (error) {
console.error('Failed to fetch messages:', error);
ElMessage.error(t('home.alerts.fetchFailed'));
} finally {
loading.value = false;
}
};
const fetchAttachments = async (messageId) => {
attachmentsLoading.value = true;
attachments.value = [];
try {
const response = await fetch(`/api/messages/${messageId}/attachments`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
attachments.value = data;
} catch (error) {
console.error('Failed to fetch attachments:', error);
} finally {
attachmentsLoading.value = false;
}
};
const selectMessage = (message) => {
selectedMessage.value = message;
if (message) {
fetchAttachments(message.id);
}
};
const deleteMessage = async (messageId) => {
try {
await ElMessageBox.confirm(
t('home.alerts.confirmDelete'),
t('home.delete'),
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
}
);
const response = await fetch(`/api/messages/${messageId}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Network response was not ok');
}
const index = messages.value.findIndex(m => m.id === messageId);
if (index !== -1) {
messages.value.splice(index, 1);
}
if (selectedMessage.value && selectedMessage.value.id === messageId) {
const newIndex = Math.max(0, index - 1);
selectedMessage.value = messages.value.length > 0 ? messages.value[newIndex] : null;
if (selectedMessage.value) {
selectMessage(selectedMessage.value);
} else {
attachments.value = [];
}
}
ElMessage.success(t('home.alerts.deleteSuccess'));
} catch (action) {
if (action === 'cancel') {
ElMessage.info(t('home.alerts.actionCancelled'));
} else {
console.error('Failed to delete message:', action);
ElMessage.error(t('home.alerts.deleteFailed'));
}
}
};
const clearInbox = async () => {
if (!recipient.value || messages.value.length === 0) return;
try {
await ElMessageBox.confirm(
t('home.alerts.confirmClearAll'),
t('home.clearInbox'),
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
}
);
const response = await fetch(`/api/messages?recipient=${recipient.value}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Network response was not ok');
}
messages.value = [];
selectedMessage.value = null;
attachments.value = [];
ElMessage.success(t('home.alerts.clearSuccess'));
} catch (action) {
if (action === 'cancel') {
ElMessage.info(t('home.alerts.actionCancelled'));
} else {
console.error('Failed to clear inbox:', action);
ElMessage.error(t('home.alerts.clearFailed'));
}
}
};
const generateRandomEmail = () => {
const names = [
'alex', 'casey', 'morgan', 'jordan', 'taylor', 'jamie', 'ryan', 'drew', 'jesse', 'pat',
'chris', 'dylan', 'aaron', 'blake', 'cameron', 'devon', 'elliot', 'finn', 'gray', 'harper',
'kai', 'logan', 'max', 'noah', 'owen', 'quinn', 'riley', 'rowan', 'sage', 'skyler'
];
const places = [
'tokyo', 'paris', 'london', 'cairo', 'sydney', 'rio', 'moscow', 'rome', 'nile', 'everest',
'sahara', 'amazon', 'gobi', 'andes', 'pacific', 'kyoto', 'berlin', 'dubai', 'seoul', 'milan',
'vienna', 'prague', 'athens', 'lisbon', 'oslo', 'helsinki', 'zürich', 'geneva', 'brussels', 'amsterdam'
];
const concepts = [
'apollo', 'artemis', 'athena', 'zeus', 'thor', 'loki', 'odin', 'freya', 'phoenix', 'dragon',
'griffin', 'sphinx', 'pyramid', 'colossus', 'acropolis', 'obelisk', 'pagoda', 'castle', 'cyberspace', 'matrix',
'protocol', 'algorithm', 'pixel', 'vector', 'photon', 'quark', 'nova', 'pulsar', 'saga', 'voyage',
'enigma', 'oracle', 'cipher', 'vortex', 'helix', 'axiom', 'zenith', 'epoch', 'nexus', 'trinity'
];
const allLists = [names, places, concepts];
const getRandomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
const listA = getRandomItem(allLists);
const listB = getRandomItem(allLists);
const word1 = getRandomItem(listA);
const word2 = getRandomItem(listB);
const number = Math.floor(Math.random() * 9000) + 1000;
const separators = ['.', '-', '_', ''];
const separator = getRandomItem(separators);
let prefix;
if (word1 === word2) {
prefix = `${word1}${number}`;
} else {
prefix = `${word1}${separator}${word2}${number}`;
}
ElMessage.success(t('home.alerts.generateRandomSuccess'));
recipient.value = `${prefix}@${domain}`;
};
const copyEmail = async () => {
if (!recipient.value) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(recipient.value);
} else {
// fallback 方案
const textArea = document.createElement('textarea');
textArea.value = recipient.value;
textArea.style.position = 'fixed'; // 防止滚动时跳动
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
if (!success) throw new Error('Fallback copy failed');
}
copyStatus.value = 'copied';
ElMessage.success(t('home.alerts.copySuccess'));
setTimeout(() => copyStatus.value = 'idle', 2000);
} catch (err) {
console.error('Copy failed:', err);
ElMessage.error(t('home.alerts.copyFailed'));
}
};
import AuthPanel from "@/components/AuthPanel.vue";
import MailWorkspace from "@/components/MailWorkspace.vue";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
</script>
<style scoped>
.message-body {
border: 1px solid #ccc;
padding: 1rem;
margin-top: 1rem;
line-height: 1.6;
white-space: normal; /* 覆盖 pre-wrap 样式 */
}
/*
为动态渲染的 HTML 内容设置样式
使用 :deep() 来穿透 Vue 的作用域限制
*/
.message-body :deep(> :first-child) {
margin-top: 0; /* 移除第一个元素的上边距 */
}
.message-body :deep(> :last-child) {
margin-bottom: 0; /* 移除最后一个元素的下边距 */
}
.message-body :deep(h1),
.message-body :deep(h2),
.message-body :deep(h3),
.message-body :deep(h4),
.message-body :deep(h5),
.message-body :deep(h6) {
margin-top: 1.2em; /* 为标题设置合适的上边距 */
margin-bottom: 0.6em; /* 减小标题和下方内容的间距 */
font-weight: 600;
}
.message-body :deep(p) {
margin-top: 0;
margin-bottom: 0.8em; /* 为段落设置合适的下边距 */
}
.message-body :deep(ul),
.message-body :deep(ol) {
padding-left: 1.5em; /* 调整列表的缩进 */
margin-bottom: 0.8em;
}
.message-body :deep(blockquote) {
margin-left: 0;
padding-left: 1em;
border-left: 3px solid #ccc;
color: #666;
}
.message-body :deep(hr) {
margin: 1.5em 0;
border: 0;
border-top: 1px solid #eee;
}
.message-content-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.header-text {
flex-grow: 1;
}
.header-actions {
flex-shrink: 0;
}
</style>
<template>
<AuthPanel v-if="!auth.isAuthenticated" />
<MailWorkspace v-else :key="auth.email" />
</template>
+145 -96
View File
@@ -1,107 +1,156 @@
<script setup>
import { useLanguageStore } from '@/stores/language';
import { computed } from 'vue';
import { onMounted, reactive, ref } from "vue";
import { Delete, Refresh } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { apiRequest } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
import { useLanguageStore } from "@/stores/language";
const domain = import.meta.env.VITE_APP_DOMAIN;
const auth = useAuthStore();
const languageStore = useLanguageStore();
const router = useRouter();
const { t } = useI18n();
const health = ref(null);
const healthLoading = ref(false);
const deleteDialogOpen = ref(false);
const deleteForm = reactive({ password: "" });
const setLanguage = (lang) => {
languageStore.setLocale(lang);
};
async function loadHealth() {
healthLoading.value = true;
try {
health.value = await apiRequest("/health", { auth: false });
} catch (error) {
health.value = null;
ElMessage.error(error.message || t("common.requestFailed"));
} finally {
healthLoading.value = false;
}
}
const currentLocale = computed(() => languageStore.locale);
async function deleteAccount() {
try {
await ElMessageBox.confirm(
t("settings.deleteConfirm"),
t("settings.deleteAccount"),
{
type: "error",
confirmButtonText: t("settings.confirmDelete"),
},
);
await apiRequest("/auth/delete", {
method: "POST",
body: {
user_id: auth.session?.id || "",
password: deleteForm.password,
},
});
deleteDialogOpen.value = false;
await auth.logout();
await router.push("/");
ElMessage.success(t("settings.deleted"));
} catch (error) {
if (error !== "cancel" && error !== "close") {
ElMessage.error(error.message || t("settings.deleteFailed"));
}
}
}
onMounted(loadHealth);
</script>
<template>
<div class="settings-page">
<div class="settings-container">
<h2>{{ $t('howItWorks.title') }}</h2>
<ol>
<i18n-t keypath="howItWorks.step1" tag="li">
<template #domain>
<code>@{{ domain }}</code>
</template>
</i18n-t>
<li>{{ $t('howItWorks.step2') }}</li>
<li>{{ $t('howItWorks.step3') }}</li>
</ol>
<main class="settings-shell">
<header class="settings-heading">
<p class="section-kicker">{{ t("settings.kicker") }}</p>
<h1>{{ t("settings.title") }}</h1>
</header>
<div class="language-switcher">
<h3>{{ $t('language') }}</h3>
<button @click="setLanguage('zh')" :class="{ active: currentLocale === 'zh' }">中文</button>
<button @click="setLanguage('en')" :class="{ active: currentLocale === 'en' }">English</button>
<section class="settings-section">
<div>
<h2>{{ t("settings.language") }}</h2>
<p>{{ t("settings.languageHint") }}</p>
</div>
</div>
</div>
<div class="segmented-control compact" role="group">
<button
type="button"
:class="{ active: languageStore.locale === 'zh' }"
@click="languageStore.setLocale('zh')"
>
中文
</button>
<button
type="button"
:class="{ active: languageStore.locale === 'en' }"
@click="languageStore.setLocale('en')"
>
English
</button>
</div>
</section>
<section class="settings-section">
<div>
<h2>{{ t("settings.serviceStatus") }}</h2>
<p>{{ t("settings.serviceHint") }}</p>
</div>
<div class="capability-list">
<span :class="{ online: health?.capabilities?.database }">
{{ t("settings.database") }}
</span>
<span :class="{ online: health?.capabilities?.redis }"> Redis </span>
<span :class="{ online: health?.capabilities?.email }">
{{ t("settings.outboundEmail") }}
</span>
<button
class="icon-command"
type="button"
:title="t('common.refresh')"
:aria-label="t('common.refresh')"
:disabled="healthLoading"
@click="loadHealth"
>
<el-icon><Refresh /></el-icon>
</button>
</div>
</section>
<section v-if="auth.isAuthenticated" class="settings-section danger-zone">
<div>
<h2>{{ t("settings.deleteAccount") }}</h2>
<p>{{ t("settings.deleteHint") }}</p>
</div>
<button
class="danger-command"
type="button"
@click="deleteDialogOpen = true"
>
<el-icon><Delete /></el-icon>
{{ t("settings.deleteAccount") }}
</button>
</section>
<el-dialog
v-model="deleteDialogOpen"
:title="t('settings.deleteAccount')"
width="min(92vw, 440px)"
>
<form class="dialog-form" @submit.prevent="deleteAccount">
<label>
<span>{{ t("auth.password") }}</span>
<input
v-model="deleteForm.password"
type="password"
autocomplete="current-password"
required
/>
</label>
<button class="danger-command" type="submit">
<el-icon><Delete /></el-icon>
{{ t("settings.confirmDelete") }}
</button>
</form>
</el-dialog>
</main>
</template>
<script>
export default {
name: 'Settings',
};
</script>
<style scoped>
.settings-page {
padding: 2rem;
display: flex;
justify-content: center;
}
.settings-container {
width: 100%;
max-width: 800px;
background-color: var(--light-grey);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
h2 {
color: var(--primary-purple);
margin-top: 0;
}
ol {
padding-left: 1.5rem;
line-height: 1.8;
font-size: 1.05rem;
color: var(--dark-grey);
}
code {
background-color: var(--light-purple);
color: var(--text-light);
padding: 0.3em 0.6em;
border-radius: 6px;
font-family: monospace;
font-weight: 500;
}
.language-switcher {
margin-top: 2rem;
border-top: 1px solid var(--medium-grey);
padding-top: 1.5rem;
}
.language-switcher h3 {
margin-top: 0;
color: var(--dark-grey);
}
.language-switcher button {
margin-right: 1rem;
padding: 0.5rem 1rem;
border: 1px solid var(--medium-grey);
background-color: #ffffff;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s, color 0.3s;
}
.language-switcher button.active {
background-color: var(--primary-purple);
color: var(--text-light);
border-color: var(--primary-purple);
}
</style>
+50 -43
View File
@@ -1,46 +1,53 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import { VitePWA } from "vite-plugin-pwa";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Email Unlimit',
short_name: 'EmailUnlimit',
description: 'Your Exclusive, Unlimited, and Private Temporary Email',
theme_color: '#000000',
icons: [
{
src: 'icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
},
],
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_PROXY_TARGET || "http://127.0.0.1:3000";
return {
plugins: [
vue(),
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "Email Unlimit",
short_name: "EmailUnlimit",
description: "Private temporary mailbox workspace",
theme_color: "#20231f",
icons: [
{
src: "icons/icon.svg",
sizes: "any",
type: "image/svg+xml",
},
],
},
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: process.env.NODE_ENV === 'production' ? {} : {
'/api': {
target: 'http://localhost:5182',
changeOrigin: true,
},
server: {
proxy: {
"/auth": {
target: apiTarget,
changeOrigin: true,
},
"/api": {
target: apiTarget,
changeOrigin: true,
ws: true,
},
"/health": {
target: apiTarget,
changeOrigin: true,
},
},
'/ws': {
target: 'ws://localhost:5182',
ws: true,
},
}
},
define: {
'process.env': process.env
}
})
},
};
});