Auto-convert .msg attachments to PDF for Ivanti FP workflow
Ivanti rejects .msg (Outlook email) file uploads. Now when a user attaches a .msg file, the backend: 1. Accepts the upload (added .msg to ALLOWED_EXTENSIONS) 2. Converts it to PDF via Python (extract-msg + reportlab) 3. Sends the PDF to Ivanti instead The PDF preserves subject, sender, date, recipients, and body. Conversion applies to both initial FP submission and add-attachments resubmit endpoint. Returns 422 if conversion fails. Dependencies: extract-msg, reportlab (pip3 install)
This commit is contained in:
@@ -3,6 +3,7 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const { ivantiFormPost, ivantiPost } = require('../helpers/ivantiApi');
|
||||
@@ -14,7 +15,8 @@ const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'.pdf', '.png', '.jpg', '.jpeg', '.gif',
|
||||
'.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'
|
||||
'.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip',
|
||||
'.msg' // Outlook emails — auto-converted to PDF before upload to Ivanti
|
||||
]);
|
||||
|
||||
function isAllowedFileExtension(filename) {
|
||||
@@ -23,6 +25,33 @@ function isAllowedFileExtension(filename) {
|
||||
return ALLOWED_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a .msg file buffer to PDF. Returns { buffer, filename } or throws on failure.
|
||||
*/
|
||||
function convertMsgToPdf(originalname, buffer) {
|
||||
const tempDir = path.join(__dirname, '..', 'uploads', 'temp');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const baseName = path.basename(originalname, '.msg');
|
||||
const msgPath = path.join(tempDir, `${Date.now()}_${baseName}.msg`);
|
||||
const pdfPath = path.join(tempDir, `${Date.now()}_${baseName}.pdf`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(msgPath, buffer);
|
||||
const pythonBin = process.env.PYTHON_BIN || 'python3';
|
||||
const script = path.join(__dirname, '..', 'scripts', 'convert_msg_to_pdf.py');
|
||||
execSync(`${pythonBin} "${script}" "${msgPath}" "${pdfPath}"`, { timeout: 15000 });
|
||||
|
||||
if (!fs.existsSync(pdfPath)) throw new Error('PDF conversion produced no output');
|
||||
const pdfBuffer = fs.readFileSync(pdfPath);
|
||||
return { buffer: pdfBuffer, filename: `${baseName}.pdf`, mimetype: 'application/pdf' };
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try { fs.unlinkSync(msgPath); } catch (_) {}
|
||||
try { fs.unlinkSync(pdfPath); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function validateFpWorkflowForm(body) {
|
||||
const errors = {};
|
||||
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||
@@ -232,6 +261,20 @@ function createIvantiFpWorkflowRouter() {
|
||||
const files = req.files || [];
|
||||
for (const file of files) { if (!isAllowedFileExtension(file.originalname)) return res.status(400).json({ error: `File type not allowed: ${file.originalname}` }); }
|
||||
|
||||
// Convert .msg files to PDF before sending to Ivanti
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (path.extname(files[i].originalname).toLowerCase() === '.msg') {
|
||||
try {
|
||||
const converted = convertMsgToPdf(files[i].originalname, files[i].buffer);
|
||||
files[i].buffer = converted.buffer;
|
||||
files[i].originalname = converted.filename;
|
||||
files[i].mimetype = converted.mimetype;
|
||||
} catch (convErr) {
|
||||
return res.status(422).json({ error: `Failed to convert ${files[i].originalname} to PDF: ${convErr.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Verify queue items
|
||||
const { rows: queueRows } = await pool.query(
|
||||
@@ -805,6 +848,20 @@ function createIvantiFpWorkflowRouter() {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert .msg files to PDF before sending to Ivanti
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (path.extname(files[i].originalname).toLowerCase() === '.msg') {
|
||||
try {
|
||||
const converted = convertMsgToPdf(files[i].originalname, files[i].buffer);
|
||||
files[i].buffer = converted.buffer;
|
||||
files[i].originalname = converted.filename;
|
||||
files[i].mimetype = converted.mimetype;
|
||||
} catch (convErr) {
|
||||
return res.status(422).json({ error: `Failed to convert ${files[i].originalname} to PDF: ${convErr.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const submissionId = req.params.id;
|
||||
|
||||
|
||||
140
backend/scripts/convert_msg_to_pdf.py
Normal file
140
backend/scripts/convert_msg_to_pdf.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert Outlook .msg files to PDF for Ivanti attachment upload.
|
||||
|
||||
Usage:
|
||||
python3 convert_msg_to_pdf.py input.msg output.pdf
|
||||
|
||||
Extracts subject, sender, date, recipients, and body from the .msg file
|
||||
and renders them into a simple PDF document.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import extract_msg
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def convert_msg_to_pdf(msg_path, pdf_path):
|
||||
"""Convert a .msg file to a formatted PDF."""
|
||||
msg = extract_msg.Message(msg_path)
|
||||
|
||||
subject = msg.subject or '(No Subject)'
|
||||
sender = msg.sender or '(Unknown Sender)'
|
||||
date = msg.date or ''
|
||||
to = msg.to or ''
|
||||
cc = msg.cc or ''
|
||||
body = msg.body or '(No body content)'
|
||||
|
||||
# Clean up body text — remove excessive blank lines
|
||||
body_lines = body.split('\n')
|
||||
cleaned_lines = []
|
||||
blank_count = 0
|
||||
for line in body_lines:
|
||||
if line.strip() == '':
|
||||
blank_count += 1
|
||||
if blank_count <= 2:
|
||||
cleaned_lines.append('')
|
||||
else:
|
||||
blank_count = 0
|
||||
cleaned_lines.append(line)
|
||||
body = '\n'.join(cleaned_lines)
|
||||
|
||||
# Build PDF
|
||||
doc = SimpleDocTemplate(pdf_path, pagesize=letter,
|
||||
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
||||
topMargin=0.75*inch, bottomMargin=0.75*inch)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(name='EmailHeader', fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#333333')))
|
||||
styles.add(ParagraphStyle(name='EmailBody', fontSize=10, leading=14,
|
||||
textColor=colors.HexColor('#1a1a1a'),
|
||||
spaceAfter=6))
|
||||
styles.add(ParagraphStyle(name='EmailSubject', fontSize=13, leading=16,
|
||||
textColor=colors.HexColor('#000000'),
|
||||
spaceAfter=12, fontName='Helvetica-Bold'))
|
||||
|
||||
elements = []
|
||||
|
||||
# Header section
|
||||
elements.append(Paragraph(f"<b>{_escape(subject)}</b>", styles['EmailSubject']))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
header_data = []
|
||||
header_data.append(['From:', _escape(sender)])
|
||||
if to:
|
||||
header_data.append(['To:', _escape(to)])
|
||||
if cc:
|
||||
header_data.append(['CC:', _escape(cc)])
|
||||
if date:
|
||||
header_data.append(['Date:', str(date)])
|
||||
|
||||
if header_data:
|
||||
t = Table(header_data, colWidths=[0.6*inch, 5.9*inch])
|
||||
t.setStyle(TableStyle([
|
||||
('FONTSIZE', (0, 0), (-1, -1), 9),
|
||||
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#666666')),
|
||||
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 2),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
|
||||
]))
|
||||
elements.append(t)
|
||||
|
||||
elements.append(Spacer(1, 12))
|
||||
elements.append(Table([['']], colWidths=[6.5*inch], style=[
|
||||
('LINEBELOW', (0, 0), (-1, -1), 0.5, colors.HexColor('#cccccc'))
|
||||
]))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
# Body
|
||||
for line in body.split('\n'):
|
||||
if line.strip():
|
||||
elements.append(Paragraph(_escape(line), styles['EmailBody']))
|
||||
else:
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Footer
|
||||
elements.append(Spacer(1, 24))
|
||||
elements.append(Paragraph(
|
||||
f"<i>Converted from .msg file on {datetime.now().strftime('%Y-%m-%d %H:%M')}</i>",
|
||||
ParagraphStyle(name='Footer', fontSize=7, textColor=colors.HexColor('#999999'))
|
||||
))
|
||||
|
||||
doc.build(elements)
|
||||
msg.close()
|
||||
return pdf_path
|
||||
|
||||
|
||||
def _escape(text):
|
||||
"""Escape XML special chars for ReportLab Paragraph."""
|
||||
if not text:
|
||||
return ''
|
||||
return (str(text)
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} input.msg output.pdf", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
input_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.isfile(input_path):
|
||||
print(f"Error: {input_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = convert_msg_to_pdf(input_path, output_path)
|
||||
print(f"OK: {result}")
|
||||
Reference in New Issue
Block a user