Recieve E-mail and Save Attachments with a PHP script

Here’s something fun! If you can tell your server to send e-mail to a script, you can send e-mails to PHP. Once you are processing the e-mail with PHP you can save attachments, automatically respond to the e-mail, save it to a database, make a webpage from it…really whatever you want!

Here’s a script I am currently using. My brother is on a mission for our Church, in Peru for two years and has near weekly e-mail access but can’t do much more than e-mail. He wanted a way to easily send photos to the server via e-mail; this script is the results of his wishes.

E-mail Processing Script Features:

  1. Saves the e-mail sender, subject and body to a database
  2. Saves any attachments as files and creates an entry for those files in the database, associated with the e-mail info in #1
  3. Sends  a response back to the sender telling them what files were received and their file sizes
  4. Checks a list of allowed senders to make sure we only take files from specified addresses.

Database Setup:

If you’re going to use the database features, you’ll need a database. Here’s the SQL to create an identical setup to the one I have:

-- Here's my DB structure

SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";
-- Table structure for table `emails`

CREATE TABLE IF NOT EXISTS `emails` (
  `id` int(100) NOT NULL AUTO_INCREMENT,
  `from` varchar(250) NOT NULL,
  `subject` text NOT NULL,
  `body` text NOT NULL,
  `date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=latin1;

-- --------------------------------------------------------
-- Table structure for table `files`

CREATE TABLE IF NOT EXISTS `files` (
  `id` int(100) NOT NULL AUTO_INCREMENT,
  `email_id` int(100) NOT NULL,
  `filename` varchar(255) NOT NULL,
  `size` varchar(100) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=latin1;

Pretty straightforward I would say. The size field in the files table stores a user friendly “100Mb” type description of the size. You will need to know your database name, username, password and host name in the next step.

The Email Handling Script:

The max_time_limit variable is how long you want the script to be allowed to run. The default max for your server might be too small to handle 20Mb of attachments (the max you can send with Google).

#!/usr/bin/php -q
<?php
//  Use -q so that php doesn't print out the HTTP headers
//  Anything printed to STDOUT will be sent back to the sender as an error!

//  Config options

$max_time_limit = 600; // in seconds
// A safe place for files with trailing slash (malicious users could upload a php or executable file!)
$save_directory = "/some/folder/path";
$allowedSenders = Array('myemail@gmail.com',
    'Bob the Builder <whatever@whoever.com>'); // only people you trust!

$send_email = TRUE; // Send confirmation e-mail?
$save_msg_to_db = TRUE; // Save e-mail body to DB?

$db_host = 'localhost';
$db_un = 'stuporgl_stuporg';
$db_pass = 'c41p1r4p0r4';
$db_name = 'stuporgl_org';

// ------------------------------------------------------

set_time_limit($max_time_limit);
ini_set('max_execution_time',$max_time_limit);

global $from, $subject, $boundary, $message, $save_path,$files_uploaded;
$save_path = $save_directory;
$files_uploaded = Array();

function formatBytes(&$bytes, $precision = 2) {
    $units = array('B', 'KB', 'MB', 'GB', 'TB');

    $bytes = max($bytes, 0);
    $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
    $pow = min($pow, count($units) - 1);

    $bytes /= pow(1024, $pow);

    return round($bytes, $precision) . ' ' . $units[$pow];
} 

function process_part(&$email_part){
    global $message;

    // Max two parts. The data could have more than one \n\n in it somewhere,
    // but the first \n\n should be after the content info block
    $parts = explode("\n\n",$email_part,2);

    $info = split("\n",$parts[0]);
    $type;
    $name;
    $encoding;
    foreach($info as $line){
	if(preg_match("/Content-Type: (.*);/",$line,$matches)){
	    $type = $matches[1];
	}
	if(preg_match("/Content-Disposition: attachment; filename=\"(.*)\"/",
	    $line,$matches)){
	    $name = time() . "_" . $matches[1];
	}
	if(preg_match("/Content-Transfer-Encoding: (.*)/",$line,$matches)){
	    $encoding = $matches[1];
	}
    }

    // We don't know what it is, so we don't know how to process it
    if(!isset($type)){ return FALSE; }

    switch($type){
    case 'text/plain':
	// "But if you get a text attachment, you're going to overwrite
	// the real message!" Yes. I don't care in this case...
	$message = $parts[1];
	break;
    case 'multipart/alternative':
	// Multipart comes where the client sends the data in two formats so
	// that recipients who can't read (or don't like) fancy content
	// have another way to read it. Eg. When sending an html formatted
	// message, they will also send a plain text message
	process_multipart($info,$parts[1]);
	break;
    default:
	if(isset($name)){ // the main message will not have a file name...
	    // text/html messages won't be saved!
	    process_data($name,$encoding,$parts[1]);
	}elseif(!isset($message) && strpos($type,'text') !== FALSE){
	    $message = $parts[1]; // Going out on a limb here...capture
	    // the message
	}
	break;
    }
}

function process_multipart(&$info,&$data){
    global $message;

    $bounds;
    foreach($info as $line){
	if (preg_match("/boundary=(.*)$/",$line,$matches)){
	    $bounds = $matches[1];
	}
    }

    $multi_parts = split("--" .$bounds,$data);
    for($i = 1;$i < count($multi_parts);$i++){
	process_part($multi_parts[$i]);
    }
}

function process_data(&$name,&$encoding = 'base64' ,&$data){
    global $save_path,$files_uploaded;

    // find a filename that's not in use. There's a race condition
    // here which should be handled with flock or something instead
    // of just checking for a free filename

    $unlocked_and_unique = FALSE;
    while(!$unlocked_and_unique){
	// Find unique
	$name = time() . "_" . $name;
	while(file_exists($save_path . $name)) {
	    $name = time() . "_" . $name;
	}

	// Attempt to lock
	$outfile = fopen($save_path.$name,'w');
	if(flock($outfile,LOCK_EX)){
	    $unlocked_and_unique = TRUE;
	}else{
	    flock($outfile,LOCK_UN);
	    fclose($outfile);
	}
    }

    if($encoding == 'base64'){
	fwrite($outfile,base64_decode($data));
    }elseif($encoding == 'uuencode'){
	// I haven't actually seen this in an e-mail, but older clients may
	// still use it...not 100% sure that this will work correctly as is
	fwrite($outfile,convert_uudecode($data));
    }
    flock($outfile,LOCK_UN);
    fclose($outfile);

    // This is for readability for the return e-mail and in the DB
    $files_uploaded[$name] = formatBytes(filesize($save_path.$name));
}

// Process the e-mail from stdin
$fd = fopen('php://stdin','r');
$email = '';
while(!feof($fd)){ $email .= fread($fd,1024); }

// Headers hsould go till the first \n\n. Grab everything before that, then
// split on \n and process each line
$headers = split("\n",array_shift(explode("\n\n",$email,2)));
foreach($headers as $line){
    // The only 3 headers we care about...
    if (preg_match("/^Subject: (.*)/", $line, $matches)) {
	$subject = $matches[1];
    }
    if (preg_match("/^From: (.*)/", $line, $matches)) {
	$from = $matches[1];
    }
    if (preg_match("/boundary=(.*)$/",$line,$matches)){
	$boundary = $matches[1];
    }
}

// Check $from here and exit if it's blank or
// not someone you want to get mail from!
if(!in_array($from,$allowedSenders)){
    die("Not an allowed sender");
}

// No boundary was in the e-mail sent to us. We don't know what to do!
if(!isset($boundary)){
    die("I couldn't find an e-mail boundary. Maybe this isn't an e-mail");
}

// Split the e-mail on the found boundary
// The first part will be the header (hence $i = 1 in our loop)
// Each other chunk should have some info on the chunk,
// then \n\n then the chunk data
// Process each chunk
$email_parts = split("--" . $boundary,$email);
for($i = 1;$i < count($email_parts);$i++){
    process_part($email_parts[$i]);
}

// Put the results in the database if needed
if($save_msg_to_db){
    mysql_connect($db_host,$db_un,$db_pass);
    mysql_select_db($db_name);

    $q = "INSERT INTO `emails` (`from`,`subject`,`body`) VALUES ('" .
	mysql_real_escape_string($from) . "','" .
	mysql_real_escape_string($subject) . "','" .
	mysql_real_escape_string($message) . "')";

    mysql_query($q) or die(mysql_error());

    if(count($files_uploaded) > 0){
	$id = mysql_insert_id();
	$q = "INSERT INTO `files` (`email_id`,`filename`,`size`) VALUES ";
	$filesar = Array();
	foreach($files_uploaded as $f => $s){
	    $filesar[] = "('$id','" .
		mysql_real_escape_string($f) . "','" .
		mysql_real_escape_string($s) . "')";
	}
	$q .= implode(', ',$filesar);
	mysql_query($q) or die(mysql_error());
    }
}

// Send response e-mail if needed
if($send_email && $from != ""){
    $to = $from;
    $newmsg = "Thanks! I just uploaded the following ";
    $newmsg .= "files to your storage:\n\n";
    $newmsg .= "Filename -- Size\n";
    foreach($files_uploaded as $f => $s){
	$newmsg .= "$f -- $s\n";
    }
    $newmsg .= "\nI hope everything looks right. If not,";
    $newmsg .=  "please send me an e-mail!\n";

    mail($to,$subject,$newmsg);
}

Testing The Script:

Save an e-mail, headers and all, and upload it to your server. Cat the saved e-mail to your script to test it.

cat 'saved_email.txt' | ./process_email.php

You should get an e-mail response, see new entries in your DB and see your saved attachments. If you don’t, you can print debugging statements or use your usual PHP debugging techniques with this easy testing method.

Security:

Don’t let anonymous people upload files to your server without appropriate precautions! If someone uploaded a PHP file they could run it and easily gain access to your server that way. Protect the upload destination directory and limit who can send e-mail to this script.  There are probably a bunch of glaring security holes in this script. In my limited use situation where only two people knows the only address that can send e-mail to this script and the address to send it to, and where only I have access to the uploaded files (outside of the server root!) I’m comfortable with the level of security. For your situation, you might need to be more cautious.

EDIT: Sending Email to the Script

Initially I didn’t have any instructions on how to forward email to the script you just made. I have now posted those instructions here: Add an Email Address That Forwards to a Script. It has instructions for CPanel and a link for Exim, Sendmail and Qmail users.

Enjoy!

Share the news:
  • Google Bookmarks
  • Facebook
  • RSS
  • StumbleUpon
  • Twitter
  • LinkedIn
  • Digg
  • del.icio.us

This entry was posted in Programming, Projects and tagged , , , , , , . Bookmark the permalink.

30 Responses to Recieve E-mail and Save Attachments with a PHP script

  1. Bernard says:

    Great solution, exactly what I was searching for!

    But there is no instructions on how to send an email to the script in the first place?

    And can a cron job be used to check a mailbox? And if a new email is found, execute the script to process it?

    Thanks

  2. Pingback: Add an email address that forwards to a script | Stuporglue.org

  3. adhie says:

    I can’t get the body and attachment, just empty string, that’s all!
    how to get it? any update from your script?
    thx

  4. stuporglue says:

    Can you send me a sample email that’s not working (full headers and
    everything). Either as an attachment or directly to
    stuporglue@gmail.com would be fine. My script has only been tested on
    email from a couple of mail clients, so there may be headers that
    will break it.

    If you’ll send me a sample I can take a look at it.

  5. Andy says:

    Just what I am looking for – absolutely brilliant thank you! Just one snag… At present, it appears that the body and any attachments are not being stored in the database / saved as files. Any thoughts? Would very much appreciate some tips.

    thanks again!

  6. Marko says:

    Hello,
    Everything works fine except saving attachments. And I can’t find any errors. Any idea what could it be?

    • stuporglue says:

      If I had to guess (for both Andy’s and Marko’s problems) I would suggest checking folder permissions. Make sure that the user who runs the script has permissions to write to the designated folder, and that the database connection info and table structure are correct. Note that the script is probably run as the mail user.

  7. Jess says:

    Nice! just one quick question, how would the code differ incase there are multiple “boundary=” preg_match(“/boundary=(.*)$/”,$line,$matches))? I believe this only looks for first boundary= but emails with multiple sections i believe comes with multiple boundary=. thanks.

    • stuporglue says:

      It should handle as many parts as you’ve got.

      Here’s the relevant snippet again:

      function process_multipart(&$info,&$data){
      ...
      $multi_parts = split("--" .$bounds,$data);
      for($i = 1;$i < count($multi_parts);$i++){
      process_part($multi_parts[$i]);
      }
      }

      We do a split on the data using $bounds as the delimiter , so the data becomes an array ($multi_parts) with each array item being one bounded part of data. That part is then sent off to process_part to discover what it is and save it off if appropriate.

  8. Alex says:

    Hi im really new on programming, i follow your instructions, but im getting this mail back.. with this message.

    A message that you sent could not be delivered to one or more of its
    recipients. This is a permanent error. The following address(es) failed:

    pipe to |/home/paelladelicatessen/buzon/process_email.php

    Any tip any help i would appreciate.

    thanks

  9. Hlavco says:

    Just as a hint to anyone, you might want to use an absolute path to your save directory. I tried relative first and it didn’t work for me.

    • stuporglue says:

      An absolute path is a good practice.

      Technically a relative path will work since we’re just using fopen, but it must be relative to the directory in which the script is running (which is always the case with fopen and PHP). Which directory that is will depend on how the mail forwarder invokes the PHP script. Since the mail program is most likely running as a daemon, it’s probably running in / and will most likely start the PHP script in / as well.

      • Peter says:

        Whoooohoo! I love it when a plan comes together.

        2 issues i cam acrooss

        1- denying my email adress i had to fully add my name in ‘my name ‘ it did not like ‘myname@kula.org.uk’

        2-the directory issue- each hosting alsways has some wierd chrooting and realtivlaty like you said- but i looked in the log.file you so kindly gerenated and copied used that as my realtive directory– it had extra /home/myaccname/www_root/ instead of just /www_root/

        Excellent script ! Thanks!

  10. Roger says:

    It only works for me when i send an email from my gmail account :O He saves the attachements perfectly.

    But when i send an email via my email accounts in Thunderbird or my online Hotmail account or iPhone (also gmail through iPhone mail app) he doesn’t save the files! I also tried to send mails from the same accounts via a webmail enviroment.

    How can i fix this? :O some setting in my email?

    • stuporglue says:

      You will probably need to look at the raw email that is being sent and fix the parsing that is being done here to work with the emails you are sending.

      A better option (and what I should’ve done originally) might be to use a Perl or PHP email library to do the parsing.

      Good luck!

  11. anaa says:

    hi I followed your instructions but I’m getting this mail back
    This message was created automatically by mail delivery software.

    A message that you sent could not be delivered to one or more of its
    recipients. This is a permanent error. The following address(es) failed:

    pipe to |/home/affiza/public_html/email_script.php

    Please help. thanks in advance

    • stuporglue says:

      It looks like you are sending an email to “pipe to |/home/affiza/public_html/email_script.php” as though it were an email address.

      Instead, you will need to set up an email account on your server (eg. my_file_upload@example.com) and configure the email server to pipe (http://en.wikipedia.org/wiki/Pipeline_%28Unix%29) the emails that are recieved to your PHP script.

      Due to the number of different server setups, you’d need to consult your hosting provider for specific instructions on how to do that step.

      • anaa says:

        I have a setup an email account on my server and allowing it to forward all emails to the provided php sript and in script I have followed all instructions of yours

      • stuporglue says:

        Sorry, I don’t know. The previous user with the similar problem (Alex) didn’t have the path to his script setup correctly, but that’s a pretty simple error which you should be able to catch.

        One possibility is that your web/SSH access is chrooted (or similar), but the email is not, which could lead to a different path being the correct path.

  12. anaa says:

    OK I have fixed that problem by adding a file exim.conf in root/etc folder
    contents of that file are
    # This transport is used for handling pipe deliveries generated by alias
    # or .forward files. If the pipe generates any standard output, it is returned
    # to the sender of the message as a delivery error. Set return_fail_output
    # instead of return_output if you want this to happen only when the pipe fails
    # to complete normally. You can set different transports for aliases and
    # forwards if you want to – see the references to address_pipe in the directors
    # section below.

    address_pipe:
    driver = pipe
    return_fail_output

    virtual_address_pipe:
    driver = pipe
    group = nobody
    return_fail_output
    user = “${lookup{$domain}lsearch* {/etc/virtual/domainowners}{$value}}”

    now the second problem is I’m unable to store any attachment in the specified folder :( where am I going wrong. Please help

    Thanks

    • stuporglue says:

      Make sure that the folder’s permissions are writable by the user the script is running as.

      In your case, I believe that the group will be nobody, and the user will be whatever

      user = “${lookup{$domain}lsearch* {/etc/virtual/domainowners}{$value}}”

      evaluates to.

      On my server I think the script gets run by the mail server user.

      For testing only (because it’s a security risk) you could change the permissions to 777, see if it gets written, and then you’d know if paths are right.

      You could also add
      print `whoami`;
      to the top of the PHP script and change your exim.conf
      #...Set return_fail_output
      # instead of return_output if you want this to happen only when the pipe fails
      # to complete normally.

      This would send the results of `whoami` back to you (also do “print getcwd();”, etc. to figure out what’s going on.

      Don’t forget to fix your permissions when you’re done.

  13. Russell Harrower says:

    I found that it works fine from gmail.com but when sending attachments from mac mail it does not work. I’ll test in outlook tomorrow.

  14. Russell Harrower says:

    however the attachments are not saving form what I can see. i get this email back

    Thanks! I just uploaded the following files to your storage:

    Filename — Size
    1326110501_1326110501_Twalk.ly-iPhone.png — 199.28 KB
    1326110501_1326110501_tigerwifi.png — 334.51 KB

    however the URL that it needs to save to
    $save_directory = “/home/telecho/public_html/private/email/attachments”;

    seems not to save.

  15. stuporglue says:

    I only ever used it with Gmail. If you want compatibility with everything, you’ll probably need a better email parser, like maybe the PECL Mailparse library, or perhaps something in Perl.

  16. Russell Harrower says:

    is there away to save the whole email just incase it does not save correctly like in a blob formate or something.

  17. Russell Harrower says:

    the error I think is that mail (apple and outlook) send some attachments like images as base 64.

    Content-Type: image/png
    Content-Transfer-Encoding: base64
    Content-Disposition: attachment; filename=”Twalk.ly-iPhone.png”

    …long base64 content blob cut…

  18. stuporglue says:

    I’d start by checking permissions. The script probably runs with mail server permissions, not with web server permissions.

  19. stuporglue says:

    The problem is that my script is not looking for inline content, only for attached content. With the way you have attached the file, it is inline, hence the

    Content-Disposition: inline;
    filename=CV-IT.pdf

    If you attach it, you would instead see
    Content-Disposition: attachment; filename="CV-IT.pdf"

    The Content-Disposition handling is around line 54-64.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>