Codementor Events

Sending Attachments with PHPMailer and AJAX Upload

Published Jun 08, 2018Last updated Dec 05, 2018
Sending Attachments with PHPMailer and AJAX Upload

Challenge

A few weeks ago, I was asked to create a form that would allow the user to send their feedback with attachments. And then output all the user inputs with the attachments on a pdf file before sending it off to the appropriate recipient.

Solution

So I decided to use PHPMailer and Dompdf to help me to achieve that. Also, I decided to use AJAX upload for a better user experience.

First, you need to upload the attachment to the server, grab all the user inputs and print them on a pdf file. This pdf file will be saved to the server as well. Then we send the attachments with the pdf file to the recipient. We delete the attachment and the pdf after sending the email (if you don't want to keep them in the server).

Slim Framework

The live site is on WordPress, but I simplified the code for this post using Slim to demonstrate this solution.

AJAX

To apply AJAX to the file upload, you need FormData object from JavaScript:

# Syntax
var formData = new FormData(form)

And you need the file input in your HTML, for example:

<input type="file" id="userfile" name="userfile">

So, this is what I have on my HTML (I have two file inputs so that the user won't upload more than two files):

<div class="groups row">
  <div class="group">
      <div class="input-file">
          <input type="file" name="sender-attachments[]">
      </div>
      <a href="#" class="button-clear hide" title="Clear the image">
          <i class="fi-x-circle"></i>
      </a>
  </div>

  <div class="group">
      <div class="input-file">
          <input type="file" name="sender-attachments[]">
      </div>
      <a href="#" class="button-clear hide" title="Clear the image">
          <i class="fi-x-circle"></i>
      </a>
  </div>
</div>

While on the JavaScript side:

var formdata = false
if (window.FormData) {
  formdata = new FormData()
}

// Iterate through the elements and append it to the form data, with the right type.
target.find('input, textarea').not('input[type=file]').each(function () {
  formdata.append(this.name, this.value)
})

// Get file data.
var els = document.querySelectorAll('input[type=file]')
var i = 0
for (i = 0; i < els.length; i++) {
  // Make sure the file has content before append it to the form object.
  var file = els[i]

  if (file.files.length > 0) {
    var fileData = file.files[0]

    // Only process image files.
    if (!fileData.type.match('image.*')) {
      continue
    }

    // Appends the currently selected File of an <input type="file" id="file"> element the FormData instance
    // which is later to be sent as multipart/form-data XHR request body
    formdata.append('sender-attachments[]', fileData)
  }
}

Then all the data are sent to the server via jQuery's AJAX:

$.ajax({
  type: 'POST',
  url: target.attr('action'),
  data: formdata,
  processData: false, // tell jQuery not to process the data
  contentType: false, // tell jQuery not to set contentType
  success: function (responseData, textStatus, jqXHR) {
  ...
  ...
  ...

$_File

On the server side, you need to process the $_FILE data to make sure the file size, the extension, etc are ok:

private function uploadFiles()
{
    // Re-arrange the default file array.
    $files = [];
    if (isset($_FILES['sender-attachments']) && count($_FILES['sender-attachments']) > 0) {
        $files = $this->reArrayUploadFiles($_FILES['sender-attachments']);
    }

    // Validate the uploaded files.
    $validated = [];
    foreach ($files as $key => $file) {
        $validated[] = $this->validateUploadFiles($file, $this->maxsize, $this->whitelist);
    }

    // Scoop for any error.
    $error_upload = [];
    foreach ($validated as $key => $file) {
        if ($file ['error']) {
            if (isset($file ['name'])) {
                $error_upload[] = $file ['name'] . ' - ' . $file ['error'] . ' ';
            } else {
                $error_upload[] = $file ['error'] . ' ';
            }
        }
    }

    // Make sure no upload errors.
    if (count($error_upload) > 0) {
        throw new \Exception(implode('; ', $error_upload), 400);
    }
    return $files;
}

This tedious process is abstracted into a couple of functions - reArrayUploadFiles() and validateUploadFiles(). What reArrayUploadFiles() does is to re-order the the default array from $_FILES into a cleaner array. For example, when uploading multiple files, the $_FILES array is created in the form:

Array
(
    [name] => Array
        (
            [0] => foo.txt
            [1] => bar.txt
        )

    [type] => Array
        (
            [0] => text/plain
            [1] => text/plain
        )

    [tmp_name] => Array
        (
            [0] => /tmp/phpYzdqkD
            [1] => /tmp/phpeEwEWG
        )

    [error] => Array
        (
            [0] => 0
            [1] => 0
        )

    [size] => Array
        (
            [0] => 123
            [1] => 456
        )
)

But this is a cleaner format:

Array
(
    [0] => Array
        (
            [name] => foo.txt
            [type] => text/plain
            [tmp_name] => /tmp/phpYzdqkD
            [error] => 0
            [size] => 123
        )

    [1] => Array
        (
            [name] => bar.txt
            [type] => text/plain
            [tmp_name] => /tmp/phpeEwEWG
            [error] => 0
            [size] => 456
        )
)

After rearranging the array we can validate it with validateUploadFiles():

// Validate the file array.
function validateUploadFiles (
    $file = '',
    $maxsize = 2,
    $whitelist = []
) {
    // File upload error messages.
    // http://php.net/manual/en/features.file-upload.errors.php
    $phpFileUploadErrors = [
        0 => 'There is no error, the file uploaded with success',
        1 => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
        2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',
        3 => 'The uploaded file was only partially uploaded',
        4 => 'No file was uploaded',
        6 => 'Missing a temporary folder',
        7 => 'Failed to write file to disk.',
        8 => 'A PHP extension stopped the file upload.',
    ];

    $max_upload_size = $maxsize * 1024 * 1024; // 2MB

    // Use the default whitelist if it is not provided.
    if (count($whitelist) === 0) {
        $whitelist = [
            'image/jpeg',
            'image/png',
            'image/gif',
            'video/mpeg',
            'video/mp4',
            'application/msword',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'application/pdf'
        ];
    }

    $result = [];
    $result['error'] = 0;

    if ($file['error']) {
        $result['name'] = $file['name'];
        $result['error'] = $phpFileUploadErrors[$file['error']];
        return $result;
    }

    if (!in_array($file['type'], $whitelist)) {
        $result['name'] = $file['name'];
        $result['error'] = 'must be a jpeg, or png';
    } elseif(($file['size'] > $max_upload_size)){
        $result['name'] = $file['name'];
        $result['error'] = $this->convertToReadableSize($file['size']) . ' bytes! It must not exceed ' . $this->convertToReadableSize($max_upload_size) . ' bytes.';
    }
    return $result;
}

Dompdf

We only make the pdf after the file validation:

private function createDompdf()
{
    return new Dompdf();
}

private function makePdf(array $data, array $files)
{
    // Make pdf.
    // Instantiate and use the dompdf class
    $dompdf = $this->createDompdf();

    // Create a DOM object from a HTML file.
    $filePath = 'view/pdf.php';
    if(file_exists($filePath)) {

        // Extract the variables to a local namespace
        extract($data);

        // Start output buffering
        ob_start();

        // Include the template file
        include $filePath;

        // End buffering and return its contents
        $html = ob_get_clean();
    }

    $dompdf->loadHtml($html);

    // (Optional) Setup the paper size and orientation
    $dompdf->setPaper('A4', 'portrait');

    // Render the HTML as PDF
    $dompdf->render();

    // Save the generated PDF document to disk instead of sending it to the browser.
    $output = $dompdf->output();
    $pdfOutput = 'Quality_Control_Feedback_' . date("YmdHis") . '.pdf';
    file_put_contents($this->uploaddir . $pdfOutput, $output);

    return $this->uploaddir . $pdfOutput;
}

The template for the pdf:

<main>
    <div class="row row-pdf">
        <div class="grid-container">
            <div class="grid-x grid-padding-x">

                <div class="large-12 cell">
                    <p><b>Name:</b> <?php echo $data['sender-name']; ?></p>
                    <p><b>Contact Email:</b> <?php echo $data['sender-email']; ?></p>
                </div>

                <div class="large-12 cell">
                    <hr>
                </div>

                <div class="large-12 cell">
                     <p><b>General Description of Problem/Concern/Fault:</b></p>
                     <p><?php echo $data['sender-description']; ?></p>
                </div>

                <div class="large-12 cell">
                    <hr>
                </div>

                 <div class="large-12 cell">
                     <p><b>Image(s) attached:</b> <?php if (count($files) > 0) { echo 'Yes'; } else { echo 'No'; } ?></p>
                </div>

            </div>
        </div>
    </div>

    <div class="row row-attachments">

        <?php
        // Attachments
        if (count($files) > 0) {
            foreach ($files as $file) {
                $imagePath = $this->uploaddir . basename($file['name']);
                // $imagePath = 'http://lorempixel.com/400/200/';
        ?>
            <div class="item-image">
                <img src="<?php echo $imagePath;?>" alt="dummy"/>
                <p><?php echo basename($file['name']); ?></p>
            </div>
        <?php
            }
        }
        ?>

    </div>
</main>

PHPMailer

After the process above is done, here is how PHPMailer comes in:

private function createPHPMailer(bool $boolean)
{
    return new PHPMailer($boolean);
}

public function sendMail(
{
    // store the posted data.
    $data = $_POST;

    $mail = $this->createPHPMailer(true);
    ...
    ...
    ...

    $files = $this->uploadFiles();
    if (count($files) > 0) {
        foreach ($files as $file) {
            if (move_uploaded_file($file['tmp_name'], $this->uploaddir . basename( $file['name']))) {
                $this->uploaddir . $file['name'];
            }

            // Add attachments.
            $mail->addAttachment($this->uploaddir . basename($file['name']));
        }
    }

    // Attache pdf.
    $pdfPath = $this->makePdf($data, $files);
    if (file_exists($pdfPath)) {
        // Add attachments.
        $mail->addAttachment($pdfPath);
    }

    $mail->isHTML(true);
    $mail->Subject = 'Quality control feedback from ' . $data['sender-name'];
    $mail->Body = ' ' .
        'Name: ' . $data['sender-name'] . '<br/>' .
        'Contact Email: ' . $data['sender-email'] . '<br/>' .
        'General Description of Problem/Concern/Fault: ' . $data['sender-description']
        ;

    $mail->send();
    ...
    ...
    ...
}

We delete the uploaded files and the pdf after $mail->send():

// Remove the uploaded files.
if (count($files) > 0) {
    foreach ($files as $file) {
        $path = $this->uploaddir . basename($file['name']);
        if (file_exists($path)) {
            unlink($path);
        }
    }
}

// Delete pdf.
if (file_exists($pdfPath)) {
    unlink($pdfPath);
}

Conclusion

It is not difficult at all to send attachments in PHP and upload files with AJAX. But it can be time consuming as the process is quite tedious. There are plenty of validation and checking that must be taken into account otherwise it will end up with a bad user experience. You can download this working sample on GitHub and run it on your local machine:

# Dependencies
$ cd [my-app-name]
$ composer update

# Production build
$ php -S 0.0.0.0:8181 -t public

Make sure that the port 8181 is not occupied by any other app before accessing it at http://0.0.0.0:8181/. You should see the following screen shots on your browser:

attachement.png

This is the screen shot from the actual site in which more input fields are required:

attachement-caremed.png

Discover and read more posts from LAU TIAM KOK
get started