Skip to main content

How to Generate, Preview, Save & Email Custom Quote PDFs in Salesforce (Apex & LWC Guide)

In plain words: Salesforce provides standard template buttons to create Quote PDFs, but they have layout limitations. By combining a custom Visualforce PDF template with an Apex controller and a Lightning Quick Action, you can preview customized quotation layouts, save the binary document directly to the Quote's related list, and email the PDF attachment to the client in a single click.

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.

Custom button to save Quote PDF and send PDF in Salesforce

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 a QuoteDocument record, and dispatches the email using Messaging.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.

Visualforce Template (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.

Apex Controller (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());
        }
    }
}
Custom quote PDF preview in Salesforce

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.

LWC Template (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>
LWC JavaScript Controller (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;
            });
    }
}
360 Quote PDF Architecture Card:
  • PDF Engine: Powered by Flying Saucer HTML-to-PDF rendering via Visualforce renderAs="pdf".
  • Storage Target: Stored under the native QuoteDocument object, 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

Developer Trap: Using getContentAsPDF() in Test Contexts & Triggers
Calling 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.
Core Rule: Never use hardcoded JavaScript buttons for PDF actions. Use Visualforce for the PDF rendering layer and Lightning Web Component Quick Actions for the UI execution layer.
  • 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 > Deliverability in 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_MODE and as user to 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.