Compare commits

...

6 Commits

11 changed files with 51 additions and 16 deletions

View File

@ -1,5 +1,10 @@
FROM node:20-alpine FROM node:20-alpine
# Set timezone to Asia/Shanghai FIRST
# This ensures that any native modules compiled during npm install are linked correctly.
RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package*.json ./ COPY package*.json ./

View File

@ -29,7 +29,7 @@ app.get('/api/messages', async (req, res) => {
try { try {
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC', 'SELECT id, sender, recipient, subject, body, CAST(received_at AS CHAR) as received_at FROM emails WHERE recipient LIKE ? ORDER BY received_at DESC',
[`%${recipient}%`] [`%${recipient}%`]
); );
res.json(rows); res.json(rows);

View File

@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS emails (
sender VARCHAR(255) NOT NULL, sender VARCHAR(255) NOT NULL,
subject VARCHAR(512), subject VARCHAR(512),
body TEXT, body TEXT,
received_at DATETIME DEFAULT CURRENT_TIMESTAMP, received_at DATETIME,
raw MEDIUMBLOB raw MEDIUMBLOB
); );

View File

@ -4,7 +4,8 @@ const logger = winston.createLogger({
level: 'info', level: 'info',
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss' format: 'YYYY-MM-DD HH:mm:ss',
tz: 'Asia/Shanghai'
}), }),
winston.format.errors({ stack: true }), winston.format.errors({ stack: true }),
winston.format.splat(), winston.format.splat(),
@ -17,11 +18,23 @@ const logger = winston.createLogger({
] ]
}); });
// 在非生产环境下,添加一个带有着色的控制台输出
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({ logger.add(new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
winston.format.colorize(), winston.format.colorize(),
winston.format.simple() // 确保控制台日志也有正确格式和时区的时间戳
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
tz: 'Asia/Shanghai'
}),
winston.format.printf(({ level, message, timestamp, stack }) => {
if (stack) {
// 打印错误堆栈
return `${timestamp} ${level}: ${message}\n${stack}`;
}
return `${timestamp} ${level}: ${message}`;
})
) )
})); }));
} }

View File

@ -1,6 +1,7 @@
const { simpleParser } = require('mailparser'); const { simpleParser } = require('mailparser');
const db = require('./db'); const db = require('./db');
const emitter = require('./eventEmitter'); const emitter = require('./eventEmitter');
const logger = require('./logger');
// Helper function to convert stream to buffer // Helper function to convert stream to buffer
function streamToBuffer(stream) { function streamToBuffer(stream) {
@ -19,20 +20,25 @@ async function saveEmail(stream) {
// Now, parse the buffered email content // Now, parse the buffered email content
const parsed = await simpleParser(emailBuffer); const parsed = await simpleParser(emailBuffer);
const rawEmail = emailBuffer.toString(); // const rawEmail = emailBuffer.toString(); // We are not saving the raw email content for now.
const recipient = parsed.to ? parsed.to.text : 'undisclosed-recipients'; const recipient = parsed.to ? parsed.to.text : 'undisclosed-recipients';
const sender = parsed.from ? parsed.from.text : 'unknown-sender'; const sender = parsed.from ? parsed.from.text : 'unknown-sender';
const subject = parsed.subject || 'No Subject'; const subject = parsed.subject || 'No Subject';
const body = parsed.text || (parsed.html || ''); const body = parsed.text || (parsed.html || '');
// Manually create a timestamp for 'Asia/Shanghai' timezone
const received_at = new Date().toLocaleString('sv-SE', {
timeZone: 'Asia/Shanghai'
});
const [result] = await db.execute( const [result] = await db.execute(
'INSERT INTO emails (recipient, sender, subject, body, raw) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO emails (recipient, sender, subject, body, received_at) VALUES (?, ?, ?, ?, ?)',
[recipient, sender, subject, body, rawEmail] [recipient, sender, subject, body, received_at]
); );
const newEmailId = result.insertId; const newEmailId = result.insertId;
console.log(`Email from <${sender}> to <${recipient}> saved with ID: ${newEmailId}`); logger.info(`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) {
@ -40,7 +46,7 @@ async function saveEmail(stream) {
'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)', 'INSERT INTO email_attachments (email_id, filename, content_type, content) VALUES (?, ?, ?, ?)',
[newEmailId, attachment.filename, attachment.contentType, attachment.content] [newEmailId, attachment.filename, attachment.contentType, attachment.content]
); );
console.log(`Attachment ${attachment.filename} saved.`); logger.info(`Attachment ${attachment.filename} saved for email ID: ${newEmailId}`);
} }
} }
@ -48,15 +54,17 @@ async function saveEmail(stream) {
const [rows] = await db.execute('SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE id = ?', [newEmailId]); const [rows] = await db.execute('SELECT id, sender, recipient, subject, body, received_at FROM emails WHERE id = ?', [newEmailId]);
if (rows.length > 0) { if (rows.length > 0) {
emitter.emit('newEmail', rows[0]); emitter.emit('newEmail', rows[0]);
logger.info(`Event 'newEmail' emitted for email ID: ${newEmailId}`);
} }
} catch (error) { } catch (error) {
console.error('Failed to save email:', error); logger.error('Failed to save email:', {
// We should not exit the process here, but maybe throw the error errorMessage: error.message,
// so the caller (SMTPServer) can handle it. errorStack: error.stack
});
// Re-throw the error so the caller (SMTPServer) can handle it.
throw error; throw error;
} }
} }
module.exports = { saveEmail }; module.exports = { saveEmail };

View File

@ -17,6 +17,8 @@ services:
image: mysql:8.0 image: mysql:8.0
container_name: email-mysql container_name: email-mysql
restart: always restart: always
environment:
- TZ=Asia/Shanghai
env_file: env_file:
- compose.env - compose.env
volumes: volumes:

View File

@ -17,6 +17,8 @@ services:
image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0 image: registry.cn-hangzhou.aliyuncs.com/pull-image/mysql:8.0
container_name: email-mysql container_name: email-mysql
restart: always restart: always
environment:
- TZ=Asia/Shanghai
env_file: env_file:
- compose.full.env - compose.full.env
volumes: volumes:

View File

@ -4,6 +4,8 @@ services:
image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest image: registry.cn-hangzhou.aliyuncs.com/pull-image/email-unlimit-backend:latest
container_name: email-backend container_name: email-backend
restart: always restart: always
environment:
- TZ=Asia/Shanghai
ports: ports:
- "5182:5182" # API port - "5182:5182" # API port
- "25:25" # SMTP port - "25:25" # SMTP port

View File

@ -24,6 +24,7 @@ const messages = {
selectMail: '请从选择一封邮件查看', selectMail: '请从选择一封邮件查看',
from: '发件人', from: '发件人',
to: '收件人', to: '收件人',
received_at: '收件时间',
attachments: '附件', attachments: '附件',
delete: '删除', delete: '删除',
clearInbox: '清空收件箱', clearInbox: '清空收件箱',
@ -68,6 +69,7 @@ const messages = {
selectMail: 'Please select an email to view', selectMail: 'Please select an email to view',
from: 'From', from: 'From',
to: 'To', to: 'To',
received_at: 'Received At',
attachments: 'Attachments', attachments: 'Attachments',
delete: 'Delete', delete: 'Delete',
clearInbox: 'Clear Inbox', clearInbox: 'Clear Inbox',

View File

@ -64,6 +64,7 @@
<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>
<p><strong>{{ $t('home.received_at') }}:</strong> {{ selectedMessage.received_at }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button @click="deleteMessage(selectedMessage.id)" :title="$t('home.delete')"> <button @click="deleteMessage(selectedMessage.id)" :title="$t('home.delete')">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 103 KiB