Add EML to PDF conversion functionality (#3650)

This pull request introduces a new feature for converting EML (email)
files to PDF format, along with various customization options. It
includes backend support for the conversion process, frontend
integration for user interaction, and updates to localization and
navigation.

### Backend Changes:
* **Added EML to PDF Conversion Logic**: Implemented a new controller
`ConvertEmlToPDF` with an endpoint `/api/v1/convert/eml/pdf` to handle
EML-to-PDF conversion requests. This includes validation, support for
HTML intermediate files, and enhanced options such as attachment
handling and size limits.
(`src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java`)
* **New Model for Conversion Requests**: Introduced `EmlToPdfRequest`
class to encapsulate request parameters like attachment inclusion,
maximum attachment size, and HTML download options.
(`common/src/main/java/stirling/software/common/model/api/converters/EmlToPdfRequest.java`)
* **Dependency Update**: Added `jakarta.mail:jakarta.mail-api:2.1.3` to
the project dependencies for handling EML files. (`common/build.gradle`)


![image](https://github.com/user-attachments/assets/34c5755a-d58d-4fc6-8a51-e83ac9f4afae)


### Frontend Changes:
* **New Web Form**: Created a new HTML page `eml-to-pdf.html` for the
EML-to-PDF conversion tool, allowing users to upload EML files and
configure options.
(`src/main/resources/templates/convert/eml-to-pdf.html`)
* **Navigation Update**: Added a navigation entry for the EML-to-PDF
tool in the sidebar.
(`src/main/resources/templates/fragments/navElements.html`)


![image](https://github.com/user-attachments/assets/afbd929d-7745-4d52-8aeb-a88d21662ca6)


### Localization and UI Enhancements:
* **Localization Strings**: Added support for the EML-to-PDF tool in the
`messages_en_GB.properties` file, including titles, descriptions, and
help texts. (`src/main/resources/messages_en_GB.properties`)
* **Web Controller Update**: Added a new route `/eml-to-pdf` in
`ConverterWebController` to serve the EML-to-PDF form.
(`src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java`)

### Highlights:
* Attachment support: and Attachment section is created with fully
working PDFAnnotations, which enable users to click paperclip and
redirects to the attachment. (Requires PDF.js)
*  If attachments are present creates a catalog of attachments
* Encoding support inside the body and header for local charachters e.g:
ö,ő,ü etc..
* Optional: Users can download HTMLs, aswell as PDFs
* Advanced features for conversion that: keep links, keep as much
formatting as possible, keep images incl relative sizes, popular fonts
and many more.

### Known limitations
* Generally EML-to-HTML is very reliable however emails with complicated
layout cause problem for Weasyprint, so not all emails can reliably
converted to PDF.
* Users need PDF.js and PDFCatalog support for best attachment/embedding
support (but is not strict requirement)

### Challanges
* Embedding was a large headache, not the Embedding itself per se more
so the additional niceties such as: links, the catalog, consistent
symbols (replaced the paperclip that is generated by pdf viewer with
emoji paperclip that is consistent for everybody) and it was generally
prone all sorts of hard to diagnose issues.
* Encoding issues
* Formatting issues

However I think addressed these so shouldn't cause any additional
headache. :)

### Examples:


![image](https://github.com/user-attachments/assets/9b560216-984d-4b9f-9ae7-8975723c894d)


![image](https://github.com/user-attachments/assets/98c7a67d-82d4-4f5a-bf42-8ebc4be18b42)


![image](https://github.com/user-attachments/assets/30a53fc9-9636-4090-b5b0-0866cc054c6c)


![image](https://github.com/user-attachments/assets/80c2d109-5259-4d3f-b97a-00b513d547e9)



Closes #503

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Balázs Szücs 2025-06-08 22:26:01 +02:00 committed by GitHub
parent 47ac4a4730
commit 9fbb0325b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1881 additions and 2 deletions

View File

@ -43,7 +43,7 @@ dependencies {
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.9'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8"
api 'jakarta.mail:jakarta.mail-api:2.1.3'
compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor "org.projectlombok:lombok:$lombokVersion"

View File

@ -0,0 +1,39 @@
package stirling.software.common.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class EmlToPdfRequest extends PDFFile {
// fileInput is inherited from PDFFile
@Schema(
description = "Include email attachments in the PDF output",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "false")
private boolean includeAttachments = false;
@Schema(
description = "Maximum attachment size in MB to include (default 10MB, range: 1-100)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "10",
minimum = "1",
maximum = "100")
private int maxAttachmentSizeMB = 10;
@Schema(
description = "Download HTML intermediate file instead of PDF",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "false")
private boolean downloadHtml = false;
@Schema(
description = "Include CC and BCC recipients in header (if available)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "true")
private boolean includeAllRecipients = true;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,157 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.EmlToPdf;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@Slf4j
@RequiredArgsConstructor
public class ConvertEmlToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = "multipart/form-data", value = "/eml/pdf")
@Operation(
summary = "Convert EML to PDF",
description =
"This endpoint converts EML (email) files to PDF format with extensive"
+ " customization options. Features include font settings, image constraints, display modes, attachment handling,"
+ " and HTML debug output. Input: EML file, Output: PDF"
+ " or HTML file. Type: SISO")
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput();
String originalFilename = inputFile.getOriginalFilename();
// Validate input
if (inputFile.isEmpty()) {
log.error("No file provided for EML to PDF conversion.");
return ResponseEntity.badRequest()
.body("No file provided".getBytes(StandardCharsets.UTF_8));
}
if (originalFilename == null || originalFilename.trim().isEmpty()) {
log.error("Filename is null or empty.");
return ResponseEntity.badRequest()
.body("Please provide a valid filename".getBytes(StandardCharsets.UTF_8));
}
// Validate file type - support EML
String lowerFilename = originalFilename.toLowerCase();
if (!lowerFilename.endsWith(".eml")) {
log.error("Invalid file type for EML to PDF: {}", originalFilename);
return ResponseEntity.badRequest()
.body("Please upload a valid EML file".getBytes(StandardCharsets.UTF_8));
}
String baseFilename = Filenames.toSimpleFileName(originalFilename); // Use Filenames utility
try {
byte[] fileBytes = inputFile.getBytes();
if (request.isDownloadHtml()) {
try {
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
log.info("Successfully converted EML to HTML: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
htmlContent.getBytes(StandardCharsets.UTF_8),
baseFilename + ".html",
MediaType.TEXT_HTML);
} catch (IOException | IllegalArgumentException e) {
log.error("HTML conversion failed for {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
("HTML conversion failed: " + e.getMessage())
.getBytes(StandardCharsets.UTF_8));
}
}
// Convert EML to PDF with enhanced options
try {
byte[] pdfBytes =
EmlToPdf.convertEmlToPdf(
runtimePathConfig.getWeasyPrintPath(), // Use configured WeasyPrint path
request,
fileBytes,
originalFilename,
false,
pdfDocumentFactory);
if (pdfBytes == null || pdfBytes.length == 0) {
log.error("PDF conversion failed - empty output for {}", originalFilename);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
"PDF conversion failed - empty output"
.getBytes(StandardCharsets.UTF_8));
}
log.info("Successfully converted EML to PDF: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
pdfBytes, baseFilename + ".pdf", MediaType.APPLICATION_PDF);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("EML to PDF conversion was interrupted for {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8));
} catch (IllegalArgumentException e) {
String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8));
} catch (RuntimeException e) {
String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8));
}
} catch (IOException e) {
log.error("File processing error for EML to PDF: {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("File processing error".getBytes(StandardCharsets.UTF_8));
}
}
private static @NotNull String buildErrorMessage(Exception e, String originalFilename) {
String errorMessage;
if (e.getMessage() != null && e.getMessage().contains("Invalid EML")) {
errorMessage =
"Invalid EML file format. Please ensure you've uploaded a valid email"
+ " file ("
+ originalFilename
+ ").";
} else if (e.getMessage() != null && e.getMessage().contains("WeasyPrint")) {
errorMessage =
"PDF generation failed for "
+ originalFilename
+ ". This may be due to complex email formatting.";
} else {
errorMessage = "Conversion failed for " + originalFilename + ": " + e.getMessage();
}
return errorMessage;
}
}

View File

@ -7,7 +7,6 @@ import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.common.util.CheckProgramInstall;
@Controller
@ -121,4 +120,11 @@ public class ConverterWebController {
model.addAttribute("currentPage", "pdf-to-pdfa");
return "convert/pdf-to-pdfa";
}
@GetMapping("/eml-to-pdf")
@Hidden
public String convertEmlToPdfForm(Model model) {
model.addAttribute("currentPage", "eml-to-pdf");
return "convert/eml-to-pdf";
}
}

View File

@ -621,6 +621,22 @@ home.HTMLToPDF.title=HTML to PDF
home.HTMLToPDF.desc=Converts any HTML file or zip to PDF
HTMLToPDF.tags=markup,web-content,transformation,convert
#eml-to-pdf
home.EMLToPDF.title=Email to PDF
home.EMLToPDF.desc=Converts email (EML) files to PDF format including headers, body, and inline images
EMLToPDF.tags=email,conversion,eml,message,transformation,convert,mail
EMLToPDF.title=Email To PDF
EMLToPDF.header=Email To PDF
EMLToPDF.submit=Convert
EMLToPDF.downloadHtml=Download HTML intermediate file instead of PDF
EMLToPDF.downloadHtmlHelp=This allows you to see the HTML version before PDF conversion and can help debug formatting issues
EMLToPDF.includeAttachments=Include attachments in PDF
EMLToPDF.maxAttachmentSize=Maximum attachment size (MB)
EMLToPDF.help=Converts email (EML) files to PDF format including headers, body, and inline images
EMLToPDF.troubleshootingTip1=Email to HTML is a more reliable process, so with batch-processing it is recommended to save both
EMLToPDF.troubleshootingTip2=With a small number of Emails, if the PDF is malformed, you can download HTML and override some of the problematic HTML/CSS code.
EMLToPDF.troubleshootingTip3=Embeddings, however, do not work with HTMLs
home.MarkdownToPDF.title=Markdown to PDF
home.MarkdownToPDF.desc=Converts any Markdown file to PDF

View File

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{EMLToPDF.title}, header=#{EMLToPDF.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon convertto">email</span>
<span class="tool-header-text" th:text="#{EMLToPDF.header}"></span>
</div>
<form method="post" enctype="multipart/form-data" th:action="@{'/api/v1/convert/eml/pdf'}" class="mt-4">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.eml,message/rfc822')}">
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" name="downloadHtml" id="downloadHtml">
<label class="form-check-label" for="downloadHtml" th:text="#{EMLToPDF.downloadHtml}"></label>
<div class="form-text" th:text="#{EMLToPDF.downloadHtmlHelp}"></div>
</div>
<div id="pdfOnlyOptions">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" name="includeAttachments" id="includeAttachments" checked>
<label class="form-check-label" for="includeAttachments" th:text="#{EMLToPDF.includeAttachments}"></label>
</div>
<div class="mb-3">
<label for="maxAttachmentSizeMB" class="form-label" th:text="#{EMLToPDF.maxAttachmentSize}"></label>
<input type="number" class="form-control" id="maxAttachmentSizeMB" name="maxAttachmentSizeMB" value="10" min="1" max="100">
</div>
</div>
<div class="mb-4">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse"
data-bs-target="#info" aria-expanded="false" aria-controls="info" th:text="#{info}">
</button>
<div class="collapse mt-3" id="info">
<div class="card card-body">
<p class="mb-2" th:text="#{EMLToPDF.help}"></p>
<ul class="mb-0">
<li th:text="#{EMLToPDF.troubleshootingTip1}"></li>
<li th:text="#{EMLToPDF.troubleshootingTip2}"></li>
<li th:text="#{EMLToPDF.troubleshootingTip3}"></li>
</ul>
</div>
</div>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{EMLToPDF.submit}"></button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const downloadHtml = document.getElementById('downloadHtml');
const pdfOnlyOptions = document.getElementById('pdfOnlyOptions');
const submitBtn = document.getElementById('submitBtn');
function updateFormState() {
if (pdfOnlyOptions && submitBtn) {
pdfOnlyOptions.style.display = downloadHtml.checked ? 'none' : 'block';
submitBtn.textContent = downloadHtml.checked ? 'Download HTML' : '[[#{EMLToPDF.submit}]]';
}
}
if (downloadHtml) {
downloadHtml.addEventListener('change', updateFormState);
updateFormState();
}
});
</script>
</body>
</html>

View File

@ -59,6 +59,9 @@
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('markdown-to-pdf', 'markdown', 'home.MarkdownToPDF.title', 'home.MarkdownToPDF.desc', 'MarkdownToPDF.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('eml-to-pdf', 'email', 'home.EMLToPDF.title', 'home.EMLToPDF.desc', 'EMLToPDF.tags', 'convertto')}">
</div>
</div>
</div>
<div id="groupConvertFrom" class="feature-group">