Building A PHP-Based Mail Client (part 3)

Find out how to construct and send MIME-encoded email messages with PHP.

The Road Ahead

In the previous segment of this case study, I taught you a little bit about how MIME attachments work, and demonstrated a few functions to handle multipart MIME email. At the end of that article, you had a fully-functional mail reader, though not one, alas, that allowed you to actually compose, forward or reply to a message.

This concluding segment will rectify that problem, enhancing the application already developed by adding support for these important functions. Additionally, it will demonstrate PHP's HTTP upload capabilities, illustrate the process of constructing a MIME message (complete with attachments) and provide links to further reading on the topic.

Composing Yourself

You'll remember, from our discussion of the "view.php" script, that the generated page includes a series of command buttons at the top.

<!-- command buttons from view.php -->
<!-- some HTML snipped out for readability -->
    <td><a href="compose.php">Compose</a></td>
    <td><a href="reply.php?id=<?php echo $id; ?>">Reply</a></td>
    <td><a href="forward.php?id=<?php echo $id; ?>">Forward</a></td>
    <td><a href="delete.php?dmsg[]=<?php echo $id; ?>">Delete</a></td>
    <td><a href="list.php">Messages</a></td>

The first (and simplest) of these is the "compose.php" script, which merely creates a blank form representing a new email message.

<?php
// compose.php - compose new message

// includes and session check
?>
<html>
<head>
</head>
<body bgcolor="White">

<?php
// page header
?>

<!-- commands - snipped -->

<table border="0" cellspacing="1" cellpadding="5" width="100%">
<form action="send.php" method="post" enctype="multipart/form-data">

<tr>
<td valign=top><font face="Verdana" size="-1"><b>From: </b></font></td>
<td valign=top width=100%><input type="Text" name="from" size="30" maxlength="75" value="<?php echo $SESSION_USER_NAME . "@" . $SESSION_MAIL_HOST; ?>">
</td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>To: </b></font></td>
<td valign=top><input type="Text" name="to" size="30"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Cc: </b></font></td>
<td valign=top><input type="Text" name="cc" size="30"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Bcc: </b></font></td>
<td valign=top><input type="Text" name="bcc" size="30"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Subject: </b></font></td>
<td valign=top><input type="Text" name="subject" size="50"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Message: </b></font></td>
<td valign=top bgcolor="White"><textarea name="body" cols="60" rows="15" wrap="VIRTUAL" ></textarea></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Attachment: </b></font></td>
<td valign=top bgcolor="White"><input type="file" name="attachment" size="20"></td>
</tr>

</form>
</table>

</body>
</html>

Here's what it looks like:

There are a couple of things to be noted here. First, the

<input type="file" name="attachment" size="20">

construct near the end of the form. This form construct creates a "Browse..." button on the form, which allows for file selection and upload through the browser; I plan to allow the user to upload message attachments though this construct.

Second, note the form encoding type and method:

<form action="send.php" method="post" enctype="multipart/form-data">

<!-- snipped out HTML -->

</form>

This encoding type and method must be specified whenever you attempt file upload over HTTP. PHP's official site has a manual page devoted to the topic - take a look at http://download.php.net/manual/en/features.file-upload.php and then come back for more.

Return To Sender

Next up, "reply.php", which receives a message ID from "view.php"; it uses this message ID to determine which message has been selected for replying. Stripped to its essence, this is an HTML form similar to the one you just saw, with some additional code to format the message and its headers appropriately.

<?php
// reply.php - reply to message

// includes and session checks

// check for required values
if (!$id)
{
    header("Location: error.php?ec=4");
    exit;
}

// open POP connection
$inbox = @imap_open ("{". $SESSION_MAIL_HOST . "/pop3:110}", $SESSION_USER_NAME, $SESSION_USER_PASS) or header("Location: error.php?ec=3");
?>
<html>
<head>
</head>
<body bgcolor="White">

<?php
// page header

// get message headers for specified message
$headers = imap_header($inbox, $id);

// check for Reply-To: header and use if available
if($headers->reply_toaddress)
{
    $to = trim($headers->reply_toaddress);
}
else
{
    $to = trim($headers->fromaddress);
}

// check for Re: in subject header
if(strtolower(substr(trim($headers->Subject), 0, 3)) == "re:")
{
$subject = $headers->Subject;
}
else
{
$subject = "Re: " . $headers->Subject;
}

// get message structure and parse
$structure = imap_fetchstructure($inbox, $id);
if(sizeof($structure->parts) > 1)
{
    $sections = parse($structure);
    $attachments = get_attachments($sections);
}
?>

<table width="100%" border="0" cellspacing="3" cellpadding="5">
<tr>
    <td width="100%">&nbsp;</td>
    <td valign=top align=center><a href="compose.php"><img src="images/compose.gif" width=50 height=50 alt="" border="0"><br><font face="Verdana" size="-2">Compose</font></a></td>
    <td valign=top align=center><a href="list.php"><img src="images/list.gif" width=50 height=50 alt="" border="0"><br><font face="Verdana" size="-2">Messages </font></a></td>
    <td valign=top align=center><a href="javascript:document.forms[0].submit()"><img src="images/send.gif" width=50 height=50 alt="" border="0"><br><font face="Verdana" size="-2">Send!</font></a></td>
</tr>
</table>

<table width="100%" border="0" cellspacing="0" cellpadding="5">
<tr>
    <td bgcolor="#C70D11"><font size="-1">&nbsp;</font></td>
</tr>
</table>

<table border="0" cellspacing="1" cellpadding="5" width="100%">
<form action="send.php" method="POST" enctype="multipart/form-data">

<tr>
<td valign=top><font face="Verdana" size="-1"><b>From: </b></font></td>
<td valign=top width=100%><input type="Text" name="from" size="30" maxlength="75" value="<?php echo $SESSION_USER_NAME . "@" . $SESSION_MAIL_HOST; ?>">
</td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>To: </b></font></td>
<td valign=top><input type="Text" name="to" size="30" value="<?php echo $to; ?>"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Cc: </b></font></td>
<td valign=top><input type="Text" name="cc" size="30"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Bcc: </b></font></td>
<td valign=top><input type="Text" name="bcc" size="30"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Subject: </b></font></td>
<td valign=top><input type="Text" name="subject" size="50" value="<?php echo $subject; ?>"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Message: </b></font></td>
<td valign=top bgcolor="White">
<textarea name="body" cols="60" rows="15" wrap="VIRTUAL" >
<?php
// attribution line
echo "\n\n\nOn " . $headers->Date . ", you wrote: \n>";

// iterate through message parts
if(is_array($sections))
{
    for($x=0; $x<sizeof($sections); $x++)
    {
        // if text type, display
        if($sections[$x]["type"] == "text/plain" && $sections[$x]["disposition"] != "attachment")
        {
        echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_fetchbody($inbox, $id, $sections[$x]["pid"]))));
        }
    }
}
else
{
echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_body($inbox, $id))));
}
?>
</textarea></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Attachment: </b></font></td>
<td valign=top bgcolor="White"><input type="file" name="attachment" size="20"></td>
</tr>

</form>
</table>

</body>
</html>

<?php
// clean up
imap_close($inbox);
?>

This is a little more complicated than "compose.php", since I need to first retrieve the original message from the mail server and then pre-fill the form with values sourced from that message. For example, I need to fill the To: field with the email address of the message's original sender (or the contents of the Reply-To: header, if it exists),

<?php
// check for Reply-To: header and use if available
if($headers->reply_toaddress)
{
    $to = trim($headers->reply_toaddress);
}
else
{
    $to = trim($headers->fromaddress);
}
?>

insert the subject line from the original message with a "Re: " prefix (if one doesn't already exist),

<?php
// check for Re: in subject header
if(strtolower(substr(trim($headers->Subject), 0, 3)) == "re:")
{
    $subject = $headers->Subject;
}
else
{
    $subject = "Re: " . $headers->Subject;
}
?>

add an attribution line,

<?php
// attribution line
echo "\n\n\nOn " . $headers->Date . ", you wrote: \n>";
?>

and quote the text within the message body.

<?php
echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_body($inbox, $id))));
?>

Here, too, I need to run the parse() function to look for attachments, and only display text attachments within the message body (this code snippet was previously explained in the second segment of this article).

<?php
// get message structure and parse
$structure = imap_fetchstructure($inbox, $id);
if(sizeof($structure->parts) > 1)
{
    $sections = parse($structure);
    $attachments = get_attachments($sections);
}

// iterate through message parts
if(is_array($sections))
{
    for($x=0; $x<sizeof($sections); $x++)
    {
        // if text type, display
        if($sections[$x]["type"] == "text/plain" && $sections[$x]["disposition"] != "attachment")
        {
        echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_fetchbody($inbox, $id, $sections[$x]["pid"]))));
        }
    }
}
else
{
    echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_body($inbox, $id))));
}
?>

Finally, since users should have the ability to add attachments to a reply, the form also includes a "file" input type and a "multipart/form-data" form encoding type.

<form action="send.php" method="POST" enctype="multipart/form-data">

<!-- snipped out HTML -->

<input type="file" name="attachment" size="20">

<!-- snipped out HTML -->

</form>

Here's what it looks like:

Coming Forward

The third of this merry trio is "forward.php", which also receives a message ID from "view.php"; it uses this message ID to determine which message has been selected for forwarding.

Of the three forms, "forward.php" has perhaps the most work to do. The form generated by "compose.php" is almost completely empty, while that generated by "reply.php" has only to worry about importing the correct headers and message body from the original message. The "forward.php" script, though, has to perform all the functions of "reply.php" and also handle attachments that may be embedded in the original message.

Consequently, the code for "forward.php" is a hybrid what you've already seen in "reply.php" and "view.php" - take a look:

<?php
// forward.php - forward message

// includes and session check

// check for missing values
if (!$id)
{
    header("Location: error.php?ec=4");
    exit;
}

$inbox = @imap_open ("{". $SESSION_MAIL_HOST . "/pop3:110}", $SESSION_USER_NAME, $SESSION_USER_PASS) or header("Location: error.php?ec=3");
?>
<html>
<head>
</head>
<body bgcolor="White">

<?php
// page header

// get message headers and structure
$headers = imap_header($inbox, $id);
$structure = imap_fetchstructure($inbox, $id);

// if multipart mail, parse
if(sizeof($structure->parts) > 1)
{
    $sections = parse($structure);
    $attachments = get_attachments($sections);
}
?>

<table width="100%" border="0" cellspacing="3" cellpadding="5">
<tr>
    <td width="100%">&nbsp;</td>
    <td valign=top align=center><a href="compose.php"><img src="images/compose.gif" width=50 height=50 alt="" border="0"><br><font face="Verdana" size="-2">Compose</font></a></td>
    <td valign=top align=center><a href="list.php"><img src="images/list.gif" width=50 height=50 alt="" border="0"><br><font face="Verdana" size="-2">Messages </font></a></td>
    <td valign=top align=center><a href="javascript:document.forms[0].submit()"><img src="images/send.gif" width=50 height=50 alt="" border="0"><br><font face="Verdana" size="-2">Send!</font></a></td>
</tr>
</table>

<table width="100%" border="0" cellspacing="0" cellpadding="5">
<tr>
    <td bgcolor="#C70D11"><font size="-1">&nbsp;</font></td>
</tr>
</table>

<table border="0" cellspacing="1" cellpadding="5" width="100%">
<form action="send.php" method="POST" enctype="multipart/form-data">

<tr>
<td valign=top><font face="Verdana" size="-1"><b>From: </b></font></td>
<td valign=top width=100%><input type="Text" name="from" size="30" maxlength="75" value="<?php echo $SESSION_USER_NAME . "@" . $SESSION_MAIL_HOST; ?>">
</td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>To: </b></font></td>
<td valign=top><input type="Text" name="to" size="30"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Cc: </b></font></td>
<td valign=top><input type="Text" name="cc" size="30"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Bcc: </b></font></td>
<td valign=top><input type="Text" name="bcc" size="30"></td>
</tr>
<?php
// empty subject correction
if ($headers->Subject == "")
{
    $subject = "No subject";
}
else
{
    $subject = $headers->Subject;
}
?>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Subject: </b></font></td>
<td valign=top><input type="Text" name="subject" size="50" value="<?php echo "Fw: " . $subject; ?>"></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Message: </b></font></td>
<td valign=top bgcolor="White">
<textarea name="body" cols="60" rows="15" wrap="VIRTUAL" >
<?php
// display message body with forward symbol >
echo "\n\n";
echo ">From: $headers->fromaddress\n";
echo ">To: $headers->toaddress\n";
if($headers->ccaddress)
{
echo ">Cc: $headers->ccaddress\n";
}
echo ">Date: $headers->Date\n";
echo ">Subject: $headers->Subject\n";

// if multipart, display text parts
if(is_array($sections))
{
    for($x=0; $x<sizeof($sections); $x++)
    {
        //if(substr($sections[$x]["type"], 0, 4) == "text")
        if($sections[$x]["type"] == "text/plain" && $sections[$x]["disposition"] != "attachment")
        {
        echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_fetchbody($inbox, $id, $sections[$x]["pid"]))));
        }
    }
}
else
{
    // else display body
    echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_body($inbox, $id))));
}
?>
</textarea></td>
</tr>

<tr>
<td valign=top><font face="Verdana" size="-1"><b>Attachments: </b></font></td>
<td valign=top bgcolor="White"><input type="file" name="attachment" size="20">
<?php
// if attachments exist
// display as list of checkboxes
if (is_array($attachments))
{
?>
<br>
<font face="Verdana" size="-1"><ul>
<?php
    for($x=0; $x<sizeof($attachments); $x++)
    {
        echo "<input type=checkbox checked name=amsg[] value=" . $attachments[$x]["pid"] . ">" . $attachments[$x]["name"] . " (" . ceil($attachments[$x]["size"]/1024) . " KB)<br>";
    }

?>

</ul></font>
<?php
}
?>
</td>
</tr>
<input type="hidden" name="id" value="<?php echo $id; ?>">
</form>
</table>

</body>
</html>

<?php
// clean up
imap_close($inbox);
?>

As is routine by now, the first part of the script checks for a valid session and then opens up a connection to the user's mailbox on the POP3 server. The supplied message ID is then used to retrieve the structure and headers for the specified message, and parse() it for attachments.

<?php
$inbox = @imap_open ("{". $SESSION_MAIL_HOST . "/pop3:110}", $SESSION_USER_NAME, $SESSION_USER_PASS) or header("Location: error.php?ec=3");

// get message headers and structure
$headers = imap_header($inbox, $id);
$structure = imap_fetchstructure($inbox, $id);

// if multipart mail, parse
if(sizeof($structure->parts) > 1)
{
    $sections = parse($structure);
    $attachments = get_attachments($sections);
}
?>

Unlike "reply.php", which has to read the original message's headers and pre-fill the form's recipient and subject fields appropriately, "forward.php" has to display these headers within the message body itself.

<?php
// display message body with forward symbol >
echo "\n\n";
echo ">From: $headers->fromaddress\n";
echo ">To: $headers->toaddress\n";
if($headers->ccaddress)
{
echo ">Cc: $headers->ccaddress\n";
}
echo ">Date: $headers->Date\n";
echo ">Subject: $headers->Subject\n";
?>

Next, the same code seen previously in "reply.php" is used to isolate and print the text sections of the message.

<?php
// if multipart, display text parts
if(is_array($sections))
{
    for($x=0; $x<sizeof($sections); $x++)
    {
        //if(substr($sections[$x]["type"], 0, 4) == "text")
        if($sections[$x]["type"] == "text/plain" && $sections[$x]["disposition"] != "attachment")
        {
        echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_fetchbody($inbox, $id, $sections[$x]["pid"]))));
        }
    }
}
else
{
    // else display body
    echo str_replace("\n", "\n>", htmlspecialchars(trim(imap_body($inbox, $id))));
}
?>

A list of the original message's attachments are also displayed, together with checkboxes which allow the user to select which ones should get forwarded.

<?php
// if attachments exist
// display as list of checkboxes
if (is_array($attachments))
{
?>
<br>
<font face="Verdana" size="-1"><ul>
<?php
    for($x=0; $x<sizeof($attachments); $x++)
    {
        echo "<input type=checkbox checked name=amsg[] value=" . $attachments[$x]["pid"] . ">" . $attachments[$x]["name"] . " (" . ceil($attachments[$x]["size"]/1024) . " KB)<br>";
    }

?>

Pay attention to the checkboxes - when the form is submitted, the array $amsg will contain the part IDs of the attachments selected for inclusion in the forwarded message. I'll be using this array extensively in the next script.

And, just to make things interesting, how about also allowing the user to upload and add a new attachment to the forwarded message?

<form action="send.php" method="POST" enctype="multipart/form-data">

<!-- snipped out HTML -->

<input type="file" name="attachment" size="20">

<!-- snipped out HTML -->

Here's what it all looks like:

Setting Boundaries

Now, if you've been paying attention, you'll have noticed one rather unusual thing about the three forms you've just seen. All of them point to the same form processor, "send.php". This might seem a somewhat odd choice on my part - after all, the three forms described above are all slightly different in character from each other, and you might think that writing a single processor for all three of them would be fairly complex - but bear with me and you'll see how it actually streamlines the entire mail delivery process.

You'll remember, from the second part of this article, how I had come up with a standard process flow to be followed while sending mail. The process involved first building the headers, then adding the message body and, if one or more attachments exist, encoding and appending them to the message body, with a unique boundary marker separating the various sections.

This is exactly what "send.php" is going to do. Take a look:

<?php

// send.php - send message

// includes and session check

// ensure that at least one field has values
if (!$to && !$cc && !$bcc)
{
    header("Location: error.php?ec=6");
    exit;
}

// check for valid file if attachment exists
if ($attachment_name && $attachment_size <= 0)
{
    header("Location: error.php?ec=7");
    exit;
}

// append signature to body
$sig = "\r\n--\r\nVisit http://www.melonfire.com/community/columns/trog/ for articles\r\nand tutorials on PHP, Python, Perl, MySQL, JSP and XML\r\n";
$body .= $sig;

// put all addresses into a single string
if($to) { $addresses .= $to . ","; }
if($cc) { $addresses .= $cc . ","; }
if($bcc) { $addresses .= $bcc . ","; }

// split address list into array
$all = split(",", $addresses);

// clean addresses
array_walk($all, "clean_address");

// validate each address further here
for($x=0; $x<sizeof($all); $x++)
{
    if($all[$x] == "")
    {
    continue;
    }

    if(!validate_email($all[$x]))
    {
    header("Location: error.php?ec=8");
    exit;
    }
}

// build message headers
$headers = "From: $from\r\n";

if($cc) { $headers .= "Cc: $cc\r\n"; }
if($bcc) { $headers .= "Bcc: $bcc\r\n"; }

// if attachments exist
if($attachment_name || sizeof($amsg) > 0)
{

// create a MIME boundary string
$boundary = "=====MELONFIRE." . md5(uniqid(time())) . "=====";

// add MIME data to the message headers
$headers .= "MIME-Version:1.0\r\n";
$headers .= "Content-Type: multipart/mixed; \r\n\tboundary=\"$boundary\"\r\n\r\n";

// start building a MIME message
// first part is always the message body
// encode as 7-bit text
$str = "--" . $boundary . "\r\n";
$str .= "Content-Type: text/plain;\r\n\tcharset=\"us-ascii\"\r\n";
$str .= "Content-Transfer-Encoding: 7bit\r\n\r\n";
$str .= "$body\r\n\r\n";

    // if forwarded message, array $amsg[] will exist
    // and contain list of attachments to be forwarded
    if (sizeof($amsg) > 0)
    {
    // open message to be forwarded and parse it to find attachment
    $inbox = @imap_open ("{". $SESSION_MAIL_HOST . "/pop3:110}", $SESSION_USER_NAME, $SESSION_USER_PASS) or header("Location: error.php?ec=3");
    $structure = imap_fetchstructure($inbox, $id);
    $sections = parse($structure);

        // go through attachment list and message section
        // if a match exists, create a MIME section and import that attachment into the message
        // do this as many times as there are attachments to be included
        for ($x=0; $x<sizeof($amsg); $x++)
        {
            for ($y=0; $y<sizeof($sections); $y++)
            {
                if($amsg[$x] == $sections[$y]["pid"])
                {
                $data = imap_fetchbody($inbox, $id, $sections[$y]["pid"]);
                $str .= "--" . $boundary . "\r\n";
                $str .= "Content-Type: " . $sections[$y]["type"] . ";\r\n\tname=\"" . $sections[$y]["name"] . "\"\r\n";
                $str .= "Content-Transfer-Encoding: base64\r\n";
                $str .= "Content-Disposition: attachment; \r\n\tfilename=\"" . $sections[$y]["name"] . "\"\r\n\r\n";
                $str .= $data . "\r\n";
                }
            }

        }
    }

    // if an uploaded attachment exists
    // encode it and attach it as a MIME-encoded section
    if ($attachment_name)
    {
    $fp = fopen($attachment, "rb");
    $data = fread($fp, filesize($attachment));
    $data = chunk_split(base64_encode($data));
    fclose($fp);

    // add the MIME data
    $str .= "--" . $boundary . "\r\n";
    $str .= "Content-Type: " . $attachment_type . ";\r\n\tname=\"" . $attachment_name . "\"\r\n";
    $str .= "Content-Transfer-Encoding: base64\r\n";
    $str .= "Content-Disposition: attachment; \r\n\tfilename=\"" . $attachment_name . "\"\r\n\r\n";
    $str .= $data . "\r\n";
    }

// all done
// add the final MIME boundary
$str .= "\r\n--$boundary--\r\n";

// assign the contents of $str to $body
// note that the original contents of $body will be lost
$body = $str;
}

// send out the message
if(mail($to, $subject, $body, $headers))
{
    $status = "Your message was successfully sent.";
}
else
{
    $status = "An error occurred while sending your message.";
}
?>
<html>
<head>
</head>
<body bgcolor="White">

<?php
$title = "Send Message";
include("header.php");
?>

<font face="Verdana" size="-1">
<?php echo $status; ?>
<p>
You can now <a href="list.php">return to the message list</a>, or <a href="compose.php">compose another message</a>.
</font>

</body>
</html>

Yes, this is pretty complicated - but fear not, all will be explained.

  1. The first order of business is to perform certain basic checks on the data that is being submitted to "send.php". Consequently, in addition to the routine session check, I've added some code to ensure that the message has at least one recipient.
<?php
// ensure that at least one field has values
if (!$to && !$cc && !$bcc)
{
    header("Location: error.php?ec=6");
    exit;
}
?>
  1. If an attachment has been uploaded, it's also a good idea to verify that the upload was successful. In order to perform these checks, I'm using the four variables created by PHP whenever a file is uploaded - $attachment holds the temporary file name assigned by PHP to the uploaded file, $attachment_size is the size of the uploaded file, $attachment _type holds the MIME type, and $attachment_name holds the original name of the file (you can read about this in detail at http://download.php.net/manual/en/features.file-upload.php)
<?php
// check for valid file if attachment exists
if ($attachment_name && $attachment_size <= 0)
{
    header("Location: error.php?ec=7");
    exit;
}
?>
  1. Next, I need to perform some basic validation on the addresses entered into the form. I already have a function - do you remember validate_email()? - to perform this validation, but I need to first clean up the addresses entered into the form and reduce them to the user@host.name form.

The following code performs the address validation, redirecting the browser to the error handler if any of the addresses turn out to be invalid.

<?php
// put all addresses into a single string
if($to) { $addresses .= $to . ","; }
if($cc) { $addresses .= $cc . ","; }
if($bcc) { $addresses .= $bcc . ","; }

// split address list into array
$all = split(",", $addresses);

// clean addresses
array_walk($all, "clean_address");

// validate each address further here
for($x=0; $x<sizeof($all); $x++)
{
    if($all[$x] == "")
    {
    continue;
    }

    if(!validate_email($all[$x]))
    {
    header("Location: error.php?ec=8");
    exit;
    }
}
?>

In case you're wondering, the clean_address() function is a tiny little function to reduce an email address to the user@host.name form, removing (among other things) whitespace, angle brackets and real names from the address text.

<?php
// remove extraneous stuff from email addresses
// returns an email address stripped of everything but the address itself
function clean_address(&$val, $index)
{
    // clean out whitespace
    $val = trim($val);
    // look for angle braces
    $begin = strrpos($val, "<");
    $end = strrpos($val, ">");
    if ($begin !== false)
    {
        // return whatever is between the angle braces
        $val = substr($val, ($begin+1), $end-$begin-1);
    }
}
?>
  1. The next step is to actually create headers reflecting the recipient information.
<?php
// build message headers
$headers = "From: $from\r\n";

if($cc) { $headers .= "Cc: $cc\r\n"; }
if($bcc) { $headers .= "Bcc: $bcc\r\n"; }
?>

Assuming that no attachments exist, this is a great place to stop; all that's left to do is send the message using PHP's mail() function. But the attachment handling code is where all the meat really is - and it's explained on the next page.

Under Construction

In the event that attachments do exist, a couple of additional steps are required:

  1. First, a unique boundary marker needs to be generated in order to separate the distinct parts of the message from each other. This boundary needs to be added to the message headers so that MIME-compliant mail clients know where to begin and end extraction of message parts.
<?php
// if attachments exist
if($attachment_name || sizeof($amsg) > 0)
{

// create a MIME boundary string
// feel free to be original here!
$boundary = "=====MELONFIRE." . md5(uniqid(time())) . "=====";

// add MIME data to the message headers
$headers .= "MIME-Version:1.0\r\n";
$headers .= "Content-Type: multipart/mixed; \r\n\tboundary=\"$boundary\"\r\n\r\n";

// start building a MIME message

}
?>

Note that the term "attachment" here refers to two types of attachments: an uploaded attachment, or (only in the case of forwarded message), an attachment included from the original message. My message construction code must account for both types of attachments.

  1. Next, a MIME message needs to be constructed, with the message body sent as the first section and the encoded attachments following it. Note the additional "--" that has to be appended to the specified boundary within the message itself - omit it and your MIME message will not be parsed correctly by a MIME-compliant mail client (you may remember this from the second part of this article).
<?php
// if attachments exist
if($attachment_name || sizeof($amsg) > 0)
{
// create a MIME boundary string

// start building a MIME message
// first part is always the message body
// encode as 7-bit text
$str = "--" . $boundary . "\r\n";
$str .= "Content-Type: text/plain;\r\n\tcharset=\"us-ascii\"\r\n";
$str .= "Content-Transfer-Encoding: 7bit\r\n\r\n";
$str .= "$body\r\n\r\n";

// handle forwarded messages

}
?>

At this point, $str holds the message body.

  1. Assuming that this is a forwarded message which includes attachments, I'll need to first retrieve the selected attachment(s) from the original message. This involves connecting to the mail server, retrieving the message structure, parsing it with my custom parse() function, and pulling out the text-encoded attachment.
<?php
// if attachments exist
if($attachment_name || sizeof($amsg) > 0)
{

// MIME message construction code

    // if forwarded message, array $amsg[] will exist
    // and contain list of attachments to be forwarded
    if (sizeof($amsg) > 0)
    {
    // open message to be forwarded and parse it to find attachment
    $inbox = @imap_open ("{". $SESSION_MAIL_HOST . "/pop3:110}", $SESSION_USER_NAME, $SESSION_USER_PASS) or header("Location: error.php?ec=3");
    $structure = imap_fetchstructure($inbox, $id);
    $sections = parse($structure);

        // go through attachment list and message section
        // if a match exists, create a MIME section and import that attachment into the message
        // do this as many times as there are attachments to be included
        for ($x=0; $x<sizeof($amsg); $x++)
        {
            for ($y=0; $y<sizeof($sections); $y++)
            {
                if($amsg[$x] == $sections[$y]["pid"])
                {
                $data = imap_fetchbody($inbox, $id, $sections[$y]["pid"]);
                $str .= "--" . $boundary . "\r\n";
                $str .= "Content-Type: " . $sections[$y]["type"] . ";\r\n\tname=\"" . $sections[$y]["name"] . "\"\r\n";
                $str .= "Content-Transfer-Encoding: base64\r\n";
                $str .= "Content-Disposition: attachment; \r\n\tfilename=\"" . $sections[$y]["name"] . "\"\r\n\r\n";
                $str .= $data . "\r\n";
                }
            }

        }
    }

// handle uploaded attachments
}
?>

Each included attachment is then added to the message string ($str) that is being constructed, with appropriate headers to describe the attachment type and name.

  1. Next, we need some code to handle uploaded attachments (the second type). In this case, the uploaded file is in binary format and needs to be first encoded into BASE64 format before being attached to the message.
<?php
// if attachments exist
if($attachment_name || sizeof($amsg) > 0)
{

// MIME message construction code

// handle forwarded messages

    // if an uploaded attachment exists
    // encode it and attach it as a MIME-encoded section
    if ($attachment_name)
    {
    $fp = fopen($attachment, "rb");
    $data = fread($fp, filesize($attachment));
    $data = chunk_split(base64_encode($data));
    fclose($fp);

    // add the MIME data
    $str .= "--" . $boundary . "\r\n";
    $str .= "Content-Type: " . $attachment_type . ";\r\n\tname=\"" . $attachment_name . "\"\r\n";
    $str .= "Content-Transfer-Encoding: base64\r\n";
    $str .= "Content-Disposition: attachment; \r\n\tfilename=\"" . $attachment_name . "\"\r\n\r\n";
    $str .= $data . "\r\n";
    }

}
// all done
?>

The binary attachment is first converted into a text-based BASE64 representation with PHP's base64_encode() function, and then added to the message string ($str). Note the chunk_split() function, used to split the BASE64-encoded text into data chunks suitable for insertion into an email message, and the HTTP upload variables created by PHP, used to add information to the Content-Type: and Content-Disposition: headers.

  1. With all attachments handled, the final task is to end the MIME message with the boundary marker and an additional "--" at the end, signifying the end of the message.
<?php
// all done
// add the final MIME boundary
$str .= "\r\n--$boundary--\r\n";

// assign the contents of $str to $body
// note that the original contents of $body will be lost
$body = $str;
?>
  1. Finally, mail() the message out and display a status message indicating whether or not the mail was accepted for delivery. Note that the Bcc: header is not correctly processed on Windows systems.
<?php
// send out the message
if(mail($to, $subject, $body, $headers))
{
    $status = "Your message was successfully sent.";
}
else
{
    $status = "An error occurred while sending your message.";
}
?>

Whew! That was complicated, but I think the effort was worth it. This script can now accept data entered into any of the three forms described previously, in addition to possessing the intelligence necessary to encode uploaded attachments or import forwarded ones. And it works like a charm - try it and see for yourself!

When Things Go Wrong...

The last script - an extremely simple one - is the error handler, "error.php". If you look at the source code, you'll notice many links to this script, each one passing it a cryptic error code via the $ec variable. Very simply, "error.php" intercepts the variable and converts it to a human-readable error message, which is then displayed to the user.

<?php
// error.php - error handler

switch($ec)
{
    // login failure
    case 1:
    $message = "An error occurred while logging you in. Please verify your account information and <a href=logout.php>log in again</a>.";
    break;

    // session authentication failure
    case 2:
    $message = "An error occurred while performing your request. Please <a href=logout.php>log in again</a>.";
    break;

    // POP3 connection problem
    case 3:
    $message = "A connection could not be opened to the mail server. Please verify your account information and <a href=logout.php>log in again</a>.";
    break;

    // missing variable
    case 4:
    $message = "An error occurred while performing your request. Please <a href=logout.php>log in again</a>.";
    break;

    // email addresses absent
    case 6:
    $message = "Your message could not be processed, as it contained no valid recipient addresses. Please <a href=compose.php>try again</a>.";
    break;

    // attachment problem
    case 7:
    $message = "An error occurred while uploading the message attachment. Please <a href=compose.php>try again</a>.";
    break;

    // email addresses invalid
    case 8:
    $message = "Your message could not be processed, as it contained one or more invalid email addresses. Please <a href=compose.php>try again</a>.";
    break;

    // everything else
    default:
    $message = "An unspecified error occurred while performing your request. Please <a href=logout.php>log in again</a>.";
    break;
}

?>
<html>
<head>
</head>
<body bgcolor="White">

<?php
// page header
?>

<font face="Verdana" size="-1">
<?php echo $message; ?>
</font>

</body>
</html>

Here's what it looks like.

Simple and elegant - not to mention flexible. Found a new error? No problem - assign it an error code and let "error.php" know.

Game Over

And that's about it for this case study. We've covered a whole range of different things over the past couple of weeks - session management, HTTP headers and file upload, code modularization, MIME attachments, mail server connection and message retrieval, and a whole lot more - and I hope you found the process interesting and entertaining.

This case study, though slightly longer than usual, also demonstrates that application development for the Web requires a great deal more than just a knowledge of PHP. In order to develop an efficient, scalable and error-free Web application, developers must have a fundamental understanding of the protocols and design principles of the Internet, and must be able to apply this knowledge judiciously, always selecting the technology and approach that is optimal for a particular situation.

This is no easy task - it can take years to develop this approach - but the pay-off, both in terms of better-designed code and an overall feeling of satisfaction, is well worth it. Putting together this mail client was, at times, a frustrating exercise in trial and error, but I've come out the other end with a greater understanding of how email works, and a greater respect for the people whose job involves designing and implementing email solutions.

I found the following resources invaluable during the development process - if you're interested in learning more about the various topics discussed in this case, you should make it a point to visit them:

The PHP manual page on POP3 functions, at http://download.php.net/manual/en/ref.imap.php

The PHP manual page on session management functions, at http://download.php.net/manual/en/ref.session.php

The PHP manual page on HTTP upload, at http://download.php.net/manual/en/features.file-upload.php

The PHP manual page on string functions, at http://download.php.net/manual/en/ref.strings.php

The PHP manual page on mail functions, at http://download.php.net/manual/en/ref.mail.php

The official POP3 RFC, at http://www.faqs.org/rfcs/rfc1939.html

The official SMTP RFC, at http://www.faqs.org/rfcs/rfc2821.html

The official MIME RFC, at http://www.faqs.org/rfcs/rfc2045.html

It should be noted at this point that this project is by no means complete. Since this prototype was introduced for internal use a few days back, a number of minor bugs have been reported, and some additional capabilities requested. Consequently, the source code provided in this article should be treated as pre-release code, unsuitable for use in production environments; I still have a ways to go before releasing this as a product to our customer.

Among the features requested: the ability to upload multiple attachments at a time; to support HTML-encoded messages; to allow sorting of the message listing by name, size, date and owner; to allow for group replies; to limit the number of files displayed per page; to change the default colours; and to display the name of the currently logged-in user. Unusual MIME attachments also tend to result in unpredictable behaviour - again, this is something that I need to debug and tune further.

I plan to continue adding features and optimizing code as and when time permits - if you'd like to give me a hand, or have ideas on how to improve the techniques discussed here, drop me a line and let me know. Ciao!

Note: All examples in this article have been tested on Linux/ig86 with Apache 1.3.12 and PHP 4.0.6. Examples are illustrative only, and are not meant for a production environment. Melonfire provides no warranties or support for the source code described in this article. YMMV!

This article was first published on 21 Dec 2001.