feat: Enhance backend with request/SQL logging via morgan/winston and fix frontend by correctly rendering email Markdown content using the 'marked' library.
This commit is contained in:
parent
c38a4f0f62
commit
8c649adf93
|
|
@ -3,3 +3,4 @@
|
||||||
/backend/package-lock.json
|
/backend/package-lock.json
|
||||||
/frontend/package-lock.json
|
/frontend/package-lock.json
|
||||||
/frontend/dist
|
/frontend/dist
|
||||||
|
plan.md
|
||||||
140
backend/app.js
140
backend/app.js
|
|
@ -1,13 +1,21 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const http = require('http');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const { SMTPServer } = require('smtp-server');
|
const { SMTPServer } = require('smtp-server');
|
||||||
const { saveEmail } = require('./saveEmail');
|
const { saveEmail } = require('./saveEmail');
|
||||||
|
const emitter = require('./eventEmitter');
|
||||||
|
const logger = require('./logger');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const apiPort = 5182;
|
const apiPort = 5182;
|
||||||
const smtpPort = 25;
|
const smtpPort = 25;
|
||||||
|
|
||||||
|
// Setup morgan to log HTTP requests to winston
|
||||||
|
app.use(morgan('combined', { stream: { write: message => logger.info(message.trim()) } }));
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|
@ -15,24 +23,136 @@ app.use(express.json());
|
||||||
app.get('/api/messages', async (req, res) => {
|
app.get('/api/messages', async (req, res) => {
|
||||||
const { recipient } = req.query;
|
const { recipient } = req.query;
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
|
logger.warn('Attempted to get messages without a recipient');
|
||||||
return res.status(400).send('Recipient is required');
|
return res.status(400).send('Recipient is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.execute(
|
const [rows] = await db.execute(
|
||||||
'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient = ? ORDER BY received_at DESC',
|
'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC',
|
||||||
[recipient]
|
[`%${recipient}%`]
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch emails:', error);
|
logger.error('Failed to fetch emails:', error);
|
||||||
res.status(500).send('Failed to fetch emails');
|
res.status(500).send('Failed to fetch emails');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API to get attachments for a message
|
||||||
|
app.get('/api/messages/:id/attachments', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
'SELECT id, filename, content_type FROM email_attachments WHERE email_id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch attachments:', { error, emailId: id });
|
||||||
|
res.status(500).send('Failed to fetch attachments');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API to download an attachment
|
||||||
|
app.get('/api/attachments/:attachmentId', async (req, res) => {
|
||||||
|
const { attachmentId } = req.params;
|
||||||
|
try {
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
'SELECT filename, content_type, content FROM email_attachments WHERE id = ?',
|
||||||
|
[attachmentId]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).send('Attachment not found');
|
||||||
|
}
|
||||||
|
const attachment = rows[0];
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`);
|
||||||
|
res.setHeader('Content-Type', attachment.content_type);
|
||||||
|
res.send(attachment.content);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to download attachment:', { error, attachmentId });
|
||||||
|
res.status(500).send('Failed to download attachment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API to delete a message
|
||||||
|
app.delete('/api/messages/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const [result] = await db.execute('DELETE FROM emails WHERE id = ?', [id]);
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
logger.warn('Attempted to delete a message that was not found', { id });
|
||||||
|
return res.status(404).send('Message not found');
|
||||||
|
}
|
||||||
|
logger.info('Message deleted successfully', { id });
|
||||||
|
res.status(204).send(); // No Content
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete message:', { error, id });
|
||||||
|
res.status(500).send('Failed to delete message');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API to delete all messages for a recipient
|
||||||
|
app.delete('/api/messages', async (req, res) => {
|
||||||
|
const { recipient } = req.query;
|
||||||
|
if (!recipient) {
|
||||||
|
logger.warn('Attempted to delete all messages without a recipient');
|
||||||
|
return res.status(400).send('Recipient is required');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.execute('DELETE FROM emails WHERE recipient = ?', [recipient]);
|
||||||
|
logger.info('All messages for recipient deleted successfully', { recipient });
|
||||||
|
res.status(204).send(); // No Content
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete all messages:', { error, recipient });
|
||||||
|
res.status(500).send('Failed to delete all messages');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// Create WebSocket server
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
// Map to store connections for each recipient
|
||||||
|
const clients = new Map();
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
const recipient = url.searchParams.get('recipient');
|
||||||
|
|
||||||
|
if (recipient) {
|
||||||
|
logger.info(`WebSocket client connected`, { recipient });
|
||||||
|
clients.set(recipient, ws);
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
logger.info(`WebSocket client disconnected`, { recipient });
|
||||||
|
clients.delete(recipient);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
logger.error(`WebSocket error`, { recipient, error });
|
||||||
|
clients.delete(recipient);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn('WebSocket client connected without recipient, closing.');
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.on('newEmail', (email) => {
|
||||||
|
const recipient = email.recipient;
|
||||||
|
if (clients.has(recipient)) {
|
||||||
|
const ws = clients.get(recipient);
|
||||||
|
logger.info(`Sending new email notification to`, { recipient });
|
||||||
|
ws.send(JSON.stringify(email));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Start API server
|
// Start API server
|
||||||
app.listen(apiPort, () => {
|
server.listen(apiPort, () => {
|
||||||
console.log(`Backend API server listening at http://localhost:${apiPort}`);
|
logger.info(`Backend API and WebSocket server listening at http://localhost:${apiPort}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure and start SMTP server
|
// Configure and start SMTP server
|
||||||
|
|
@ -40,23 +160,23 @@ const smtpServer = new SMTPServer({
|
||||||
authOptional: true,
|
authOptional: true,
|
||||||
disabledCommands: ['AUTH'],
|
disabledCommands: ['AUTH'],
|
||||||
onData(stream, session, callback) {
|
onData(stream, session, callback) {
|
||||||
console.log('Receiving email...');
|
logger.info('Receiving email...', { session });
|
||||||
saveEmail(stream)
|
saveEmail(stream)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Email processed and saved successfully.');
|
logger.info('Email processed and saved successfully.');
|
||||||
callback(); // Accept the message
|
callback(); // Accept the message
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Error processing email:', err);
|
logger.error('Error processing email:', err);
|
||||||
callback(new Error('Failed to process email.'));
|
callback(new Error('Failed to process email.'));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
smtpServer.on('error', err => {
|
smtpServer.on('error', err => {
|
||||||
console.error('SMTP Server Error:', err.message);
|
logger.error('SMTP Server Error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
smtpServer.listen(smtpPort, () => {
|
smtpServer.listen(smtpPort, () => {
|
||||||
console.log(`SMTP server listening on port ${smtpPort}`);
|
logger.info(`SMTP server listening on port ${smtpPort}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const mysql = require('mysql2');
|
const mysql = require('mysql2');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
|
|
@ -10,4 +11,18 @@ const pool = mysql.createPool({
|
||||||
queueLimit: 0
|
queueLimit: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = pool.promise();
|
const promisePool = pool.promise();
|
||||||
|
|
||||||
|
const originalExecute = promisePool.execute;
|
||||||
|
promisePool.execute = function(sql, params) {
|
||||||
|
logger.info('Executing SQL', { sql, params });
|
||||||
|
return originalExecute.call(this, sql, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalQuery = promisePool.query;
|
||||||
|
promisePool.query = function(sql, params) {
|
||||||
|
logger.info('Executing SQL', { sql, params });
|
||||||
|
return originalQuery.call(this, sql, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = promisePool;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
class MyEmitter extends EventEmitter {}
|
||||||
|
const emitter = new MyEmitter();
|
||||||
|
module.exports = emitter;
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
const winston = require('winston');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss'
|
||||||
|
}),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.splat(),
|
||||||
|
winston.format.json()
|
||||||
|
),
|
||||||
|
defaultMeta: { service: 'user-service' },
|
||||||
|
transports: [
|
||||||
|
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||||
|
new winston.transports.File({ filename: 'combined.log' })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.simple()
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
"mailparser": "^3.7.4",
|
"mailparser": "^3.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"smtp-server": "^3.13.4"
|
"smtp-server": "^3.13.4",
|
||||||
|
"ws": "^8.17.1",
|
||||||
|
"winston": "^3.13.0",
|
||||||
|
"morgan": "^1.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const { simpleParser } = require('mailparser');
|
const { simpleParser } = require('mailparser');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
const emitter = require('./eventEmitter');
|
||||||
|
|
||||||
// Helper function to convert stream to buffer
|
// Helper function to convert stream to buffer
|
||||||
function streamToBuffer(stream) {
|
function streamToBuffer(stream) {
|
||||||
|
|
@ -29,18 +30,26 @@ async function saveEmail(stream) {
|
||||||
'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)',
|
||||||
[recipient, sender, subject, body, rawEmail]
|
[recipient, sender, subject, body, rawEmail]
|
||||||
);
|
);
|
||||||
|
const newEmailId = result.insertId;
|
||||||
|
|
||||||
console.log(`Email from <${sender}> to <${recipient}> saved with ID: ${result.insertId}`);
|
console.log(`Email from <${sender}> to <${recipient}> saved with ID: ${newEmailId}`);
|
||||||
|
|
||||||
if (parsed.attachments && parsed.attachments.length > 0) {
|
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||||
for (const attachment of parsed.attachments) {
|
for (const attachment of parsed.attachments) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)',
|
'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)',
|
||||||
[result.insertId, attachment.filename, attachment.contentType, attachment.content]
|
[newEmailId, attachment.filename, attachment.contentType, attachment.content]
|
||||||
);
|
);
|
||||||
console.log(`Attachment ${attachment.filename} saved.`);
|
console.log(`Attachment ${attachment.filename} saved.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit an event with the new email's main information
|
||||||
|
const [rows] = await db.execute('SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE id = ?', [newEmailId]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
emitter.emit('newEmail', rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save email:', error);
|
console.error('Failed to save email:', error);
|
||||||
// We should not exit the process here, but maybe throw the error
|
// We should not exit the process here, but maybe throw the error
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
version: '3.3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<link rel="icon" href="<%= BASE_URL %>logo.svg" type="image/svg+xml">
|
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
||||||
<link rel="manifest" href="<%= BASE_URL %>manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#000000">
|
<meta name="theme-color" content="#000000">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<link rel="apple-touch-icon" href="<%= BASE_URL %>img/icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
|
||||||
<title>临时邮件</title>
|
<title>临时邮件</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -16,6 +16,6 @@
|
||||||
<strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
<strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- built files will be auto injected -->
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -3,19 +3,24 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"dev": "vite",
|
||||||
"build": "vue-cli-service build"
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"element-plus": "^2.10.4",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"register-service-worker": "^1.7.2",
|
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-i18n": "^11.1.11",
|
"vue-i18n": "^11.1.11",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"ws": "^8.18.3",
|
||||||
|
"marked": "^13.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vue/cli-plugin-pwa": "~5.0.0",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
"@vue/cli-service": "~5.0.8"
|
"vite": "^5.3.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
|
||||||
/* Global Styles & Resets */
|
/* Global Styles & Resets */
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|
@ -5,7 +6,7 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-purple: #6d28d9;
|
--primary-purple: #6d28d9;
|
||||||
|
|
@ -181,6 +182,15 @@ body {
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #ef4444; /* a nice red */
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #dc2626; /* a darker red */
|
||||||
|
}
|
||||||
|
|
||||||
/* Inbox Section */
|
/* Inbox Section */
|
||||||
.inbox-section {
|
.inbox-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -219,12 +229,36 @@ body {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--dark-grey);
|
color: var(--dark-grey);
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button:hover:not(:disabled) {
|
||||||
|
background-color: var(--medium-grey);
|
||||||
|
color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .el-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item {
|
.message-item {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,20 @@ const messages = {
|
||||||
selectMail: '请从选择一封邮件查看',
|
selectMail: '请从选择一封邮件查看',
|
||||||
from: '发件人',
|
from: '发件人',
|
||||||
to: '收件人',
|
to: '收件人',
|
||||||
|
attachments: '附件',
|
||||||
|
delete: '删除',
|
||||||
|
clearInbox: '清空收件箱',
|
||||||
|
alerts: {
|
||||||
|
enterEmail: '请输入一个邮箱地址',
|
||||||
|
fetchFailed: '无法获取邮件,请检查后端服务和 Nginx 配置是否正常。',
|
||||||
|
confirmDelete: '确定要删除这封邮件吗?',
|
||||||
|
deleteFailed: '删除邮件失败。',
|
||||||
|
confirmClearAll: '确定要清空这个收件箱的所有邮件吗?此操作不可恢复。',
|
||||||
|
clearFailed: '清空收件箱失败。',
|
||||||
|
copyFailed: '复制失败!',
|
||||||
|
actionCancelled: '操作已取消',
|
||||||
|
},
|
||||||
|
newMailNotification: '收到新邮件!',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
|
|
@ -49,6 +63,20 @@ const messages = {
|
||||||
selectMail: 'Please select an email to view',
|
selectMail: 'Please select an email to view',
|
||||||
from: 'From',
|
from: 'From',
|
||||||
to: 'To',
|
to: 'To',
|
||||||
|
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.',
|
||||||
|
copyFailed: 'Copy failed!',
|
||||||
|
actionCancelled: 'Action cancelled',
|
||||||
|
},
|
||||||
|
newMailNotification: 'New mail received!',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { createApp, watch } from 'vue'
|
import { createApp, watch } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './registerServiceWorker'
|
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
import { useLanguageStore } from './stores/language'
|
import { useLanguageStore } from './stores/language'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
@ -13,6 +14,7 @@ const pinia = createPinia()
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
const languageStore = useLanguageStore(pinia)
|
const languageStore = useLanguageStore(pinia)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import { register } from 'register-service-worker'
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
|
||||||
ready () {
|
|
||||||
console.log(
|
|
||||||
'App is being served from cache by a service worker.\n' +
|
|
||||||
'For more details, visit https://goo.gl/AFskqB'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
registered () {
|
|
||||||
console.log('Service worker has been registered.')
|
|
||||||
},
|
|
||||||
cached () {
|
|
||||||
console.log('Content has been cached for offline use.')
|
|
||||||
},
|
|
||||||
updatefound () {
|
|
||||||
console.log('New content is downloading.')
|
|
||||||
},
|
|
||||||
updated () {
|
|
||||||
console.log('New content is available; please refresh.')
|
|
||||||
},
|
|
||||||
offline () {
|
|
||||||
console.log('No internet connection found. App is running in offline mode.')
|
|
||||||
},
|
|
||||||
error (error) {
|
|
||||||
console.error('Error during service worker registration:', error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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 };
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
/*
|
||||||
|
* 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 };
|
||||||
|
|
@ -13,11 +13,8 @@
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button @click="copyEmail" type="button" class="btn-copy" :title="$t('home.copyTitle')">
|
<button @click="copyEmail" type="button" class="btn-copy" :title="$t('home.copyTitle')">
|
||||||
<span v-if="copyStatus === 'copied'">✓</span>
|
<el-icon v-if="copyStatus === 'copied'"><Check /></el-icon>
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<el-icon v-else><CopyDocument /></el-icon>
|
||||||
<path d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/>
|
|
||||||
<path d="M2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h-1v1a.5.5 0 0 1-.5.5H2.5a.5.5 0 0 1-.5-.5V6.5a.5.5 0 0 1 .5-.5H3v-1z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button @click="generateRandomEmail" type="button" class="btn btn-secondary">{{ $t('home.random') }}</button>
|
<button @click="generateRandomEmail" type="button" class="btn btn-secondary">{{ $t('home.random') }}</button>
|
||||||
|
|
@ -30,9 +27,17 @@
|
||||||
<div class="message-list">
|
<div class="message-list">
|
||||||
<div class="message-list-header">
|
<div class="message-list-header">
|
||||||
<h2>{{ $t('home.inbox') }}</h2>
|
<h2>{{ $t('home.inbox') }}</h2>
|
||||||
<button @click="fetchMessages" class="refresh-btn" :title="$t('home.refresh')">
|
<div class="header-actions">
|
||||||
↻
|
<button @click="clearInbox" :title="$t('home.clearInbox')" :disabled="messages.length === 0">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
</button>
|
</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>
|
||||||
<div v-if="loading" class="loading-state">{{ $t('home.loading') }}</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-if="messages.length === 0" class="empty-state">{{ $t('home.noMail') }}</div>
|
||||||
|
|
@ -55,12 +60,27 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="message-content-header">
|
<div class="message-content-header">
|
||||||
|
<div class="header-text">
|
||||||
<h3>{{ selectedMessage.subject }}</h3>
|
<h3>{{ selectedMessage.subject }}</h3>
|
||||||
<p class="from-line"><strong>{{ $t('home.from') }}:</strong> {{ selectedMessage.sender }}</p>
|
<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.to') }}:</strong> {{ selectedMessage.recipient }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-body">
|
<div class="header-actions">
|
||||||
{{ selectedMessage.body }}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -69,26 +89,78 @@
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
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';
|
||||||
|
|
||||||
export default {
|
const { t } = useI18n();
|
||||||
name: 'Home',
|
const recipient = ref('');
|
||||||
setup() {
|
const messages = ref([]);
|
||||||
const recipient = ref('');
|
const selectedMessage = ref(null);
|
||||||
const messages = ref([]);
|
const loading = ref(false);
|
||||||
const selectedMessage = ref(null);
|
const copyStatus = ref('idle'); // 'idle' | 'copied'
|
||||||
const loading = ref(false);
|
const domain = 'shenjianl.cn';
|
||||||
const copyStatus = ref('idle'); // 'idle' | 'copied'
|
const attachments = ref([]);
|
||||||
const domain = 'shenjianl.cn';
|
const attachmentsLoading = ref(false);
|
||||||
|
const newMailNotification = ref(false);
|
||||||
|
|
||||||
const fetchMessages = async () => {
|
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) {
|
if (!recipient.value) {
|
||||||
alert('请输入一个邮箱地址');
|
ElMessage.warning(t('home.alerts.enterEmail'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
selectedMessage.value = null; // Clear selected message on new fetch
|
selectedMessage.value = null;
|
||||||
|
attachments.value = [];
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/messages?recipient=${recipient.value}`);
|
const response = await fetch(`/api/messages?recipient=${recipient.value}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -97,21 +169,116 @@ export default {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
messages.value = data;
|
messages.value = data;
|
||||||
if (messages.value.length > 0) {
|
if (messages.value.length > 0) {
|
||||||
selectedMessage.value = messages.value[0];
|
selectMessage(messages.value[0]);
|
||||||
}
|
}
|
||||||
|
setupWebSocket(); // Setup WebSocket after fetching messages
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch messages:', error);
|
console.error('Failed to fetch messages:', error);
|
||||||
alert('无法获取邮件,请检查后端服务和 Nginx 配置是否正常。');
|
ElMessage.error(t('home.alerts.fetchFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectMessage = (message) => {
|
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;
|
selectedMessage.value = message;
|
||||||
};
|
if (message) {
|
||||||
|
fetchAttachments(message.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const generateRandomEmail = () => {
|
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 = [
|
const names = [
|
||||||
'alex', 'casey', 'morgan', 'jordan', 'taylor', 'jamie', 'ryan', 'drew', 'jesse', 'pat',
|
'alex', 'casey', 'morgan', 'jordan', 'taylor', 'jamie', 'ryan', 'drew', 'jesse', 'pat',
|
||||||
'chris', 'dylan', 'aaron', 'blake', 'cameron', 'devon', 'elliot', 'finn', 'gray', 'harper',
|
'chris', 'dylan', 'aaron', 'blake', 'cameron', 'devon', 'elliot', 'finn', 'gray', 'harper',
|
||||||
|
|
@ -151,9 +318,9 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient.value = `${prefix}@${domain}`;
|
recipient.value = `${prefix}@${domain}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyEmail = () => {
|
const copyEmail = () => {
|
||||||
if (!recipient.value) return;
|
if (!recipient.value) return;
|
||||||
|
|
||||||
const textToCopy = recipient.value;
|
const textToCopy = recipient.value;
|
||||||
|
|
@ -168,9 +335,9 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
fallbackCopy(textToCopy);
|
fallbackCopy(textToCopy);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fallbackCopy = (text) => {
|
const fallbackCopy = (text) => {
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
textArea.value = text;
|
textArea.value = text;
|
||||||
|
|
||||||
|
|
@ -191,31 +358,16 @@ export default {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fallback copy failed: ', err);
|
console.error('Fallback copy failed: ', err);
|
||||||
alert('复制失败!');
|
ElMessage.error(t('home.alerts.copyFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showCopySuccess = () => {
|
const showCopySuccess = () => {
|
||||||
copyStatus.value = 'copied';
|
copyStatus.value = 'copied';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyStatus.value = 'idle';
|
copyStatus.value = 'idle';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
recipient,
|
|
||||||
messages,
|
|
||||||
selectedMessage,
|
|
||||||
loading,
|
|
||||||
copyStatus,
|
|
||||||
domain,
|
|
||||||
fetchMessages,
|
|
||||||
selectMessage,
|
|
||||||
generateRandomEmail,
|
|
||||||
copyEmail,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { defineConfig } 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: process.env.NODE_ENV === 'production' ? {} : {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5182',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:5182',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env': process.env
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
const { defineConfig } = require('@vue/cli-service')
|
|
||||||
|
|
||||||
module.exports = defineConfig({
|
|
||||||
transpileDependencies: true,
|
|
||||||
// devServer 代理配置已移除,所有代理均由外部 Nginx 处理。
|
|
||||||
pwa: {
|
|
||||||
name: 'EmailUnlimit',
|
|
||||||
themeColor: '#000000',
|
|
||||||
msTileColor: '#000000',
|
|
||||||
appleMobileWebAppCapable: 'yes',
|
|
||||||
appleMobileWebAppStatusBarStyle: 'black',
|
|
||||||
|
|
||||||
// 配置 workbox-webpack-plugin
|
|
||||||
workboxPluginMode: 'GenerateSW',
|
|
||||||
workboxOptions: {
|
|
||||||
// swSrc is required in InjectManifest mode.
|
|
||||||
// swSrc: 'dev/sw.js',
|
|
||||||
// ...other Workbox options...
|
|
||||||
}
|
|
||||||
},
|
|
||||||
chainWebpack: config => {
|
|
||||||
config.plugin('define').tap(definitions => {
|
|
||||||
Object.assign(definitions[0], {
|
|
||||||
__VUE_OPTIONS_API__: true,
|
|
||||||
__VUE_PROD_DEVTOOLS__: false,
|
|
||||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
|
|
||||||
})
|
|
||||||
return definitions
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue