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:
Jordan Ramos
2026-06-30 15:27:29 -06:00
parent a5ef696993
commit 92096f2b66
2 changed files with 198 additions and 1 deletions

View File

@@ -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;

View 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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;'))
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}")