diff --git a/.gitignore b/.gitignore index ebeea4d..2f57761 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /frontend/node_modules /backend/package-lock.json /frontend/package-lock.json -/frontend/dist \ No newline at end of file +/frontend/dist +plan.md \ No newline at end of file diff --git a/backend/app.js b/backend/app.js index 5a2d392..1442863 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,13 +1,21 @@ const express = require('express'); const cors = require('cors'); +const http = require('http'); +const { WebSocketServer } = require('ws'); const db = require('./db'); const { SMTPServer } = require('smtp-server'); const { saveEmail } = require('./saveEmail'); +const emitter = require('./eventEmitter'); +const logger = require('./logger'); +const morgan = require('morgan'); const app = express(); const apiPort = 5182; 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(express.json()); @@ -15,24 +23,136 @@ app.use(express.json()); app.get('/api/messages', async (req, res) => { const { recipient } = req.query; if (!recipient) { + logger.warn('Attempted to get messages without a recipient'); return res.status(400).send('Recipient is required'); } try { const [rows] = await db.execute( - 'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient = ? ORDER BY received_at DESC', - [recipient] + 'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC', + [`%${recipient}%`] ); res.json(rows); } catch (error) { - console.error('Failed to fetch emails:', error); + logger.error('Failed to fetch emails:', error); 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 -app.listen(apiPort, () => { - console.log(`Backend API server listening at http://localhost:${apiPort}`); +server.listen(apiPort, () => { + logger.info(`Backend API and WebSocket server listening at http://localhost:${apiPort}`); }); // Configure and start SMTP server @@ -40,23 +160,23 @@ const smtpServer = new SMTPServer({ authOptional: true, disabledCommands: ['AUTH'], onData(stream, session, callback) { - console.log('Receiving email...'); + logger.info('Receiving email...', { session }); saveEmail(stream) .then(() => { - console.log('Email processed and saved successfully.'); + logger.info('Email processed and saved successfully.'); callback(); // Accept the message }) .catch(err => { - console.error('Error processing email:', err); + logger.error('Error processing email:', err); callback(new Error('Failed to process email.')); }); }, }); smtpServer.on('error', err => { - console.error('SMTP Server Error:', err.message); + logger.error('SMTP Server Error:', err); }); smtpServer.listen(smtpPort, () => { - console.log(`SMTP server listening on port ${smtpPort}`); + logger.info(`SMTP server listening on port ${smtpPort}`); }); diff --git a/backend/db.js b/backend/db.js index dec7378..708a840 100644 --- a/backend/db.js +++ b/backend/db.js @@ -1,4 +1,5 @@ const mysql = require('mysql2'); +const logger = require('./logger'); const pool = mysql.createPool({ host: process.env.DB_HOST, @@ -10,4 +11,18 @@ const pool = mysql.createPool({ 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; diff --git a/backend/eventEmitter.js b/backend/eventEmitter.js new file mode 100644 index 0000000..b6734e5 --- /dev/null +++ b/backend/eventEmitter.js @@ -0,0 +1,4 @@ +const EventEmitter = require('events'); +class MyEmitter extends EventEmitter {} +const emitter = new MyEmitter(); +module.exports = emitter; diff --git a/backend/logger.js b/backend/logger.js new file mode 100644 index 0000000..03f6241 --- /dev/null +++ b/backend/logger.js @@ -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; diff --git a/backend/package.json b/backend/package.json index 3f23002..4d3a53a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,9 @@ "mysql2": "^3.14.2", "mailparser": "^3.7.4", "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" } } diff --git a/backend/saveEmail.js b/backend/saveEmail.js index 0d63f49..382e5d5 100644 --- a/backend/saveEmail.js +++ b/backend/saveEmail.js @@ -1,5 +1,6 @@ const { simpleParser } = require('mailparser'); const db = require('./db'); +const emitter = require('./eventEmitter'); // Helper function to convert stream to buffer function streamToBuffer(stream) { @@ -29,18 +30,26 @@ async function saveEmail(stream) { 'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)', [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) { for (const attachment of parsed.attachments) { await db.execute( '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.`); } } + + // 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) { console.error('Failed to save email:', error); // We should not exit the process here, but maybe throw the error diff --git a/docker-compose.yml b/docker-compose.yml index 7e4b00c..94bc1cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.3' - services: backend: build: ./backend diff --git a/frontend/public/index.html b/frontend/index.html similarity index 67% rename from frontend/public/index.html rename to frontend/index.html index a801360..0588132 100644 --- a/frontend/public/index.html +++ b/frontend/index.html @@ -4,11 +4,11 @@ - - + + - + 临时邮件 @@ -16,6 +16,6 @@ We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.
- + diff --git a/frontend/package.json b/frontend/package.json index 2657fea..b608b8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,19 +3,24 @@ "version": "0.1.0", "private": true, "scripts": { - "serve": "vue-cli-service serve", - "build": "vue-cli-service build" + "dev": "vite", + "build": "vite build", + "preview": "vite preview" }, "dependencies": { + "@element-plus/icons-vue": "^2.3.1", "axios": "^1.11.0", + "element-plus": "^2.10.4", "pinia": "^3.0.3", - "register-service-worker": "^1.7.2", "vue": "^3.5.18", "vue-i18n": "^11.1.11", - "vue-router": "^4.5.1" + "vue-router": "^4.5.1", + "ws": "^8.18.3", + "marked": "^13.0.2" }, "devDependencies": { - "@vue/cli-plugin-pwa": "~5.0.0", - "@vue/cli-service": "~5.0.8" + "@vitejs/plugin-vue": "^5.0.5", + "vite": "^5.3.1", + "vite-plugin-pwa": "^0.20.0" } } diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index d162d1c..90edbb3 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,3 +1,4 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap'); /* Global Styles & Resets */ *, *::before, @@ -5,7 +6,7 @@ box-sizing: border-box; } -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap'); + :root { --primary-purple: #6d28d9; @@ -181,6 +182,15 @@ body { 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 { width: 100%; @@ -219,12 +229,36 @@ body { font-size: 1.25rem; } -.refresh-btn { +.header-actions { + display: flex; + gap: 0.5rem; +} + +.header-actions button { background: none; border: none; cursor: pointer; - font-size: 1.5rem; 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 { diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index 79683c3..01c814f 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -24,6 +24,20 @@ const messages = { selectMail: '请从选择一封邮件查看', from: '发件人', to: '收件人', + attachments: '附件', + delete: '删除', + clearInbox: '清空收件箱', + alerts: { + enterEmail: '请输入一个邮箱地址', + fetchFailed: '无法获取邮件,请检查后端服务和 Nginx 配置是否正常。', + confirmDelete: '确定要删除这封邮件吗?', + deleteFailed: '删除邮件失败。', + confirmClearAll: '确定要清空这个收件箱的所有邮件吗?此操作不可恢复。', + clearFailed: '清空收件箱失败。', + copyFailed: '复制失败!', + actionCancelled: '操作已取消', + }, + newMailNotification: '收到新邮件!', } }, en: { @@ -49,6 +63,20 @@ const messages = { selectMail: 'Please select an email to view', from: 'From', 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!', } }, }; diff --git a/frontend/src/main.js b/frontend/src/main.js index a0c6ddc..13f6cd4 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,10 +1,11 @@ import { createApp, watch } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' -import './registerServiceWorker' 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' const app = createApp(App) @@ -13,6 +14,7 @@ const pinia = createPinia() app.use(pinia) app.use(router) app.use(i18n) +app.use(ElementPlus) const languageStore = useLanguageStore(pinia) diff --git a/frontend/src/registerServiceWorker.js b/frontend/src/registerServiceWorker.js deleted file mode 100644 index 76cede0..0000000 --- a/frontend/src/registerServiceWorker.js +++ /dev/null @@ -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) - } - }) -} diff --git a/frontend/src/utils/gravatar.js b/frontend/src/utils/gravatar.js new file mode 100644 index 0000000..4efccff --- /dev/null +++ b/frontend/src/utils/gravatar.js @@ -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 }; diff --git a/frontend/src/utils/md5.js b/frontend/src/utils/md5.js new file mode 100644 index 0000000..d3c3635 --- /dev/null +++ b/frontend/src/utils/md5.js @@ -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 }; \ No newline at end of file diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index e5eff5a..e483dfc 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -13,11 +13,8 @@ required /> @@ -30,9 +27,17 @@

{{ $t('home.inbox') }}

- +
+ + +
+
+
+ {{ $t('home.newMailNotification') }}
{{ $t('home.loading') }}
{{ $t('home.noMail') }}
@@ -55,12 +60,27 @@
-

{{ selectedMessage.subject }}

-

{{ $t('home.from') }}: {{ selectedMessage.sender }}

-

{{ $t('home.to') }}: {{ selectedMessage.recipient }}

+
+

{{ selectedMessage.subject }}

+

{{ $t('home.from') }}: {{ selectedMessage.sender }}

+

{{ $t('home.to') }}: {{ selectedMessage.recipient }}

+
+
+ +
-
- {{ selectedMessage.body }} +
+
+
{{ $t('home.loading') }}
+
+

{{ $t('home.attachments') }}

+
@@ -69,153 +89,285 @@ - + +onUnmounted(() => { + if (ws) { + ws.close(); + } +}); + +const fetchMessages = async () => { + if (!recipient.value) { + ElMessage.warning(t('home.alerts.enterEmail')); + return; + } + 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}`; + } + + recipient.value = `${prefix}@${domain}`; +}; + +const copyEmail = () => { + if (!recipient.value) return; + + const textToCopy = recipient.value; + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(textToCopy).then(() => { + showCopySuccess(); + }).catch(err => { + console.error('Modern copy failed: ', err); + fallbackCopy(textToCopy); + }); + } else { + fallbackCopy(textToCopy); + } +}; + +const fallbackCopy = (text) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + textArea.style.left = '-9999px'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + const successful = document.execCommand('copy'); + if (successful) { + showCopySuccess(); + } else { + throw new Error('Fallback copy was unsuccessful'); + } + } catch (err) { + console.error('Fallback copy failed: ', err); + ElMessage.error(t('home.alerts.copyFailed')); + } + + document.body.removeChild(textArea); +}; + +const showCopySuccess = () => { + copyStatus.value = 'copied'; + setTimeout(() => { + copyStatus.value = 'idle'; + }, 2000); +}; + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..88001e4 --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } +}) diff --git a/frontend/vue.config.js b/frontend/vue.config.js deleted file mode 100644 index 3dd1eb8..0000000 --- a/frontend/vue.config.js +++ /dev/null @@ -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 - }) - } -}) \ No newline at end of file