While standard Salesforce Quote templates cover basic invoicing, enterprise organizations frequently require custom branding, complex table calculations, custom terms, and automated emailing. Building a custom Quote PDF engine allows you to generate high-fidelity PDF documents, store them under the QuoteDocument object, and deliver them to customers without third-party document generation apps.
1. Architecture Overview: Legacy JavaScript vs. Modern Lightning Actions
In Salesforce Classic, custom buttons relied on On-Click JavaScript (REQUIRESCRIPT and sforce.apex.execute) to call WebService methods. However, JavaScript buttons are completely unsupported and blocked in Lightning Experience due to Content Security Policy (CSP) protections.
To ensure full compatibility with modern Salesforce orgs, we implement this solution using a secure, controller-backed pattern:
- Visualforce PDF Template (
renderAs="pdf"): Formats the layout, company logos, line items, and terms. - Apex Service Controller: Uses
PageReference.getContentAsPDF()to generate the binary blob, creates aQuoteDocumentrecord, and dispatches the email usingMessaging.SingleEmailMessage. - Lightning Quick Action (LWC / Aura): Provides a modal preview and triggers the server-side save-and-send process with toast notifications.
2. Step 1: Create the Custom Visualforce PDF Template
Create a Visualforce page designed with clean print CSS to render the quote document structure.
CustomQuotePDF.page)
<apex:page standardController="Quote" renderAs="pdf" showHeader="false" sidebar="false" standardStylesheets="false" applyHtmlTag="false" applyBodyTag="false">
<html>
<head>
<style>
@page {
size: letter;
margin: 25mm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: sans-serif;
font-size: 9pt;
}
}
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 10pt; color: #333; }
.header-table { width: 100%; margin-bottom: 20px; border-bottom: 2px solid #005fb2; padding-bottom: 10px; }
.items-table { width: 100%; border-collapse: collapse; margin-top: 15px; }
.items-table th { background-color: #f3f3f3; border: 1px solid #ccc; padding: 8px; text-align: left; }
.items-table td { border: 1px solid #ccc; padding: 8px; }
.total-row { font-weight: bold; background-color: #fafafa; }
</style>
</head>
<body>
<table class="header-table">
<tr>
<td><h2>QUOTATION</h2><p>Quote #: {!Quote.QuoteNumber}</p></td>
<td style="text-align: right;">
<strong>Date:</strong> <apex:outputText value="{0,date,MM/dd/yyyy}"><apex:param value="{!TODAY()}"/></apex:outputText><br/>
<strong>Prepared For:</strong> {!Quote.Contact.Name}
</td>
</tr>
</table>
<table class="items-table">
<thead>
<tr>
<th>Product</th>
<th style="text-align: right;">Quantity</th>
<th style="text-align: right;">List Price</th>
<th style="text-align: right;">Total Price</th>
</tr>
</thead>
<tbody>
<apex:repeat value="{!Quote.QuoteLineItems}" var="item">
<tr>
<td>{!item.PricebookEntry.Product2.Name}</td>
<td style="text-align: right;">{!item.Quantity}</td>
<td style="text-align: right;">${!item.UnitPrice}</td>
<td style="text-align: right;">${!item.TotalPrice}</td>
</tr>
</apex:repeat>
<tr class="total-row">
<td colspan="3" style="text-align: right;">Grand Total:</td>
<td style="text-align: right;">${!Quote.TotalPrice}</td>
</tr>
</tbody>
</table>
</body>
</html>
</apex:page>
3. Step 2: Build the Server-Side Apex Controller
The Apex class fetches the quote metadata, renders the Visualforce page into a PDF binary blob, inserts a QuoteDocument record into the database, and emails the generated PDF to the customer.
QuoteDocumentService.cls)
public with sharing class QuoteDocumentService {
@AuraEnabled
public static String saveAndEmailQuotePDF(Id quoteId) {
if (quoteId == null) {
throw new AuraHandledException('Invalid Quote ID provided.');
}
// 1. Query quote details with User Mode security
Quote currentQuote = [
SELECT Id, Name, QuoteNumber, ContactId, Contact.Name, Email, Status
FROM Quote
WHERE Id = :quoteId
WITH USER_MODE
LIMIT 1
];
if (String.isBlank(currentQuote.Email)) {
throw new AuraHandledException('The associated Contact does not have an active Email address.');
}
try {
// 2. Generate PDF Blob from Visualforce Page
PageReference pdfPage = Page.CustomQuotePDF;
pdfPage.getParameters().put('id', quoteId);
Blob pdfBlob;
if (Test.isRunningTest()) {
pdfBlob = Blob.valueOf('Mock PDF Content for Unit Test');
} else {
pdfBlob = pdfPage.getContentAsPDF();
}
// 3. Save to QuoteDocument standard related list
QuoteDocument qd = new QuoteDocument();
qd.QuoteId = quoteId;
qd.Document = pdfBlob;
insert as user qd;
// 4. Construct and send the email with attachment
Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
email.setToAddresses(new String[]{ currentQuote.Email });
email.setSubject('Quotation Details: ' + currentQuote.Name + ' (' + currentQuote.QuoteNumber + ')');
String body = 'Hello ' + (currentQuote.Contact != null ? currentQuote.Contact.Name : 'Valued Customer') + ',\n\n';
body += 'Please find attached the quotation details for ' + currentQuote.Name + '.\n\n';
body += 'Best regards,\nSales Team';
email.setPlainTextBody(body);
Messaging.EmailFileAttachment attachment = new Messaging.EmailFileAttachment();
attachment.setFileName(currentQuote.Name + '_Quotation.pdf');
attachment.setContentType('application/pdf');
attachment.setBody(pdfBlob);
email.setFileAttachments(new Messaging.EmailFileAttachment[]{ attachment });
Messaging.sendEmail(new Messaging.SingleEmailMessage[]{ email });
return 'Quote PDF successfully attached and emailed to ' + currentQuote.Email;
} catch (Exception ex) {
System.debug(LoggingLevel.ERROR, 'Failed to process Quote PDF: ' + ex.getMessage());
throw new AuraHandledException('Error processing quote: ' + ex.getMessage());
}
}
}
4. Step 3: Create the Lightning Quick Action Component
To replace deprecated JavaScript buttons, create an LWC component configured as a Lightning Quick Action. This allows users to preview the generated PDF inside a modal dialog and click a single button to save and email.
quotePdfAction.html)
<template>
<lightning-quick-action-panel header="Quote PDF Preview & Delivery">
<template if:true={isLoading}>
<lightning-spinner alternative-text="Processing..." size="medium"></lightning-spinner>
</template>
<div class="slds-m-around_small">
<iframe src={previewUrl} width="100%" height="450px" style="border: 1px solid #ddd; border-radius: 4px;"></iframe>
</div>
<div slot="footer">
<lightning-button variant="neutral" label="Cancel" onclick={handleClose}></lightning-button>
<lightning-button variant="brand" label="Save & Send Email" onclick={handleSaveAndSend} class="slds-m-left_x-small"></lightning-button>
</div>
</lightning-quick-action-panel>
</template>
quotePdfAction.js)
import { LightningElement, api } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { CloseActionScreenEvent } from 'lightning/actions';
import saveAndEmailQuotePDF from '@salesforce/apex/QuoteDocumentService.saveAndEmailQuotePDF';
export default class QuotePdfAction extends LightningElement {
@api recordId;
isLoading = false;
get previewUrl() {
return `/apex/CustomQuotePDF?id=${this.recordId}`;
}
handleClose() {
this.dispatchEvent(new CloseActionScreenEvent());
}
handleSaveAndSend() {
this.isLoading = true;
saveAndEmailQuotePDF({ quoteId: this.recordId })
.then(result => {
this.dispatchEvent(new ShowToastEvent({
title: 'Success',
message: result,
variant: 'success'
}));
this.handleClose();
})
.catch(error => {
this.dispatchEvent(new ShowToastEvent({
title: 'Operation Failed',
message: error.body ? error.body.message : error.message,
variant: 'error'
}));
})
.finally(() => {
this.isLoading = false;
});
}
}
- PDF Engine: Powered by Flying Saucer HTML-to-PDF rendering via Visualforce
renderAs="pdf". - Storage Target: Stored under the native
QuoteDocumentobject, appearing automatically in the Quote's standard PDF related list. - Email Delivery: Sends attachments asynchronously using Salesforce Single Email infrastructure without hitting sandbox limits.
- Lightning Compliance: Built using Quick Actions and LWC, ensuring zero CSP or browser deprecation issues.
5. Critical Traps & Best Practices
getContentAsPDF() in Test Contexts & TriggersCalling
PageReference.getContentAsPDF() inside unit tests throws a runtime exception: Methods defined as TestMethod do not support getContent calls. Always wrap the call in a Test.isRunningTest() conditional check to provide a mock blob during test execution. Furthermore, getContentAsPDF() treats the page render as an HTTP callout, meaning it cannot follow synchronous DML in the same transaction.
- Ensure Clean HTML in Visualforce: The PDF renderer requires strictly valid XHTML. Always close tags (e.g.,
<br/>and<img/>) and avoid modern flexbox or grid CSS that Flying Saucer does not support. - Validate Email Deliverability: Check
Setup > Deliverabilityin sandbox environments to ensure outbound email access is set to All Email during testing. - Enforce Object Security: Always execute queries and DML with
WITH USER_MODEandas userto respect Field-Level Security and profile permissions.
Summary
Developing custom Quote PDF generation in Salesforce provides complete control over document layout and customer communication. By replacing outdated On-Click JavaScript with a modern Lightning Quick Action, leveraging Visualforce for XHTML print rendering, and using Apex to manage QuoteDocument storage and email delivery, you create a robust, production-ready document workflow that runs seamlessly across desktop and mobile devices.