feat: initial commit — OST to PST/MBOX converter with Docker support
Some checks failed
Build & Push Multi-Arch Image / build-and-push (push) Has been cancelled

This commit is contained in:
Simon Cloutier
2026-05-20 13:38:46 -04:00
commit cfee611fdc
17 changed files with 3311 additions and 0 deletions

234
server.js Normal file
View File

@@ -0,0 +1,234 @@
const express = require('express');
const multer = require('multer');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3000;
// Ensure directories exist
const UPLOADS_DIR = path.join(__dirname, 'uploads');
const CONVERTED_DIR = path.join(__dirname, 'converted');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR);
if (!fs.existsSync(CONVERTED_DIR)) fs.mkdirSync(CONVERTED_DIR);
// Configure Multer for local uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOADS_DIR);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 1024 * 1024 * 1024 } // 1GB limit
});
// Serve static frontend files
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
// In-memory task tracking
const tasks = new Map();
// File upload endpoint
app.post('/api/upload', upload.single('ostFile'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const format = req.body.format === 'mbox' ? 'mbox' : 'pst';
const combineVcf = req.body.combineVcf === 'true';
const taskId = 'task-' + Date.now() + '-' + Math.round(Math.random() * 1000);
const originalName = req.file.originalname;
const baseName = path.basename(originalName, path.extname(originalName)).replace(/[^a-zA-Z0-9_-]/g, '_');
const outputFileName = `${baseName}-${Date.now()}.${format === 'mbox' ? 'zip' : 'pst'}`;
const outputPath = path.join(CONVERTED_DIR, outputFileName);
tasks.set(taskId, {
id: taskId,
originalName: originalName,
inputPath: req.file.path,
outputPath: outputPath,
outputFileName: outputFileName,
format: format,
combineVcf: combineVcf,
status: 'uploaded',
logs: [],
error: null
});
res.json({ taskId });
});
// SSE Streaming endpoint for real-time logs and status
app.get('/api/stream/:taskId', (req, res) => {
const { taskId } = req.params;
const task = tasks.get(taskId);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
// Set headers for Server-Sent Events (SSE)
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.write(`data: ${JSON.stringify({ status: 'starting', message: 'Starting Java conversion engine...' })}\n\n`);
task.status = 'converting';
// Spawn Java conversion process
const classpath = 'aspose-email-24.12-jdk16.jar:.';
const child = spawn('java', [
'-cp', classpath,
'Convert.java',
task.inputPath,
task.outputPath,
task.format,
task.combineVcf ? 'true' : 'false'
], {
cwd: __dirname
});
const folderStats = [];
let totalMessages = 0;
let duration = 0;
let buffer = '';
const sendLog = (data) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep incomplete line
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
task.logs.push(trimmed);
// Parse stats in real-time
const folderMatch = trimmed.match(/Processing Folder:\s+(.+)/);
if (folderMatch) {
const fName = folderMatch[1];
if (!folderStats.find(item => item.folder === fName)) {
folderStats.push({ folder: fName, messages: 0 });
}
}
const msgMatch = trimmed.match(/Successfully copied\s+(\d+)\s+messages to folder:\s+(.+)/);
if (msgMatch) {
const count = parseInt(msgMatch[1], 10);
const fName = msgMatch[2];
const found = folderStats.find(item => item.folder === fName);
if (found) {
found.messages = count;
} else {
folderStats.push({ folder: fName, messages: count });
}
totalMessages += count;
}
const durationMatch = trimmed.match(/Conversion completed successfully in\s+([\d\.]+)\s+seconds/);
if (durationMatch) {
duration = parseFloat(durationMatch[1]);
}
res.write(`data: ${JSON.stringify({ status: 'running', log: trimmed })}\n\n`);
}
}
};
child.stdout.on('data', sendLog);
child.stderr.on('data', sendLog);
child.on('close', (code) => {
// Handle remaining buffer
if (buffer.trim()) {
task.logs.push(buffer.trim());
res.write(`data: ${JSON.stringify({ status: 'running', log: buffer.trim() })}\n\n`);
}
// Clean up uploaded input OST file immediately to save disk space
try {
if (fs.existsSync(task.inputPath)) {
fs.unlinkSync(task.inputPath);
}
} catch (err) {
console.error('Failed to delete input file:', err);
}
if (code === 0) {
task.status = 'completed';
// Send completed state with parsed statistics
res.write(`data: ${JSON.stringify({
status: 'completed',
downloadUrl: `/api/download/${taskId}`,
fileName: task.outputFileName,
summary: {
folders: folderStats.filter(f => f.messages > 0),
totalMessages: totalMessages,
duration: duration || 0
}
})}\n\n`);
} else {
task.status = 'failed';
task.error = 'Conversion process exited with code ' + code;
res.write(`data: ${JSON.stringify({ status: 'failed', error: task.error })}\n\n`);
}
res.end();
});
req.on('close', () => {
// If client closes connection prematurely, terminate the conversion process
if (task.status === 'converting') {
child.kill();
}
});
});
// Download endpoint
app.get('/api/download/:taskId', (req, res) => {
const { taskId } = req.params;
const task = tasks.get(taskId);
if (!task || task.status !== 'completed') {
return res.status(404).send('PST file not found or task not completed.');
}
if (!fs.existsSync(task.outputPath)) {
return res.status(404).send('PST file has expired or was removed.');
}
res.setHeader('Content-Type', task.format === 'mbox' ? 'application/zip' : 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${task.outputFileName}"`);
res.sendFile(task.outputPath, (err) => {
if (err) {
console.error('Download error:', err);
}
// Clean up output file after download (or set a timeout)
setTimeout(() => {
try {
if (fs.existsSync(task.outputPath)) {
fs.unlinkSync(task.outputPath);
}
tasks.delete(taskId);
} catch (cleanupErr) {
console.error('Failed to clean up output file:', cleanupErr);
}
}, 60000); // Wait 1 minute before removing to ensure download succeeds
});
});
app.listen(PORT, () => {
console.log(`OST to PST Converter running at http://localhost:${PORT}`);
});