Creating an IMAP Email Client using PHP

Software developers often encounter situations needing access to email inboxes. Typically, they achieve this using IMAP](https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol), which stands for Internet Message Access Protocol. The standard PHP library provides IMAP functionality [PHP developer, I first turned to PHP’s built in IMAP library, however, it suffers from bugs, lacks debugging capabilities, and modifications are not feasible. Furthermore, it doesn’t offer customization of IMAP commands for leveraging the full potential of the protocol.

This article demonstrates how to build a fully functional IMAP email client using PHP from scratch. We’ll also explore how to incorporate Gmail’s special commands in the process.

We’ll encapsulate our IMAP implementation within a custom class named imap_driver. Each step of the class construction will be thoroughly explained. At the end of this article, you can download the complete source code for imap_driver.php.

Establishing a Connection

IMAP, being a connection-oriented protocol, usually operates over TCP/IP with SSL encryption. Therefore, establishing a connection is a prerequisite before executing any IMAP commands.

We need the IMAP server’s URL and port number, typically found in the service’s documentation or website. For instance, for Gmail, the URL is ssl://imap.gmail.com and the port is 993.

To check if the initialization is successful, our class constructor will remain empty, and a custom init() method will handle the connection process. If the connection fails, the method will return false:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class imap_driver
{
    private $fp;                      // file pointer
    public $error;                    // error message
    ...
    public function init($host, $port)
    {
        if (!($this->fp = fsockopen($host, $port, $errno, $errstr, 15))) {
            $this->error = "Could not connect to host ($errno) $errstr";
            return false;
        }
        if (!stream_set_timeout($this->fp, 15)) {
            $this->error = "Could not set timeout";
            return false;
        }
        $line = fgets($this->fp);     // discard the first line of the stream
        return true;
    }
    
    private function close()
    {
        fclose($this->fp);
    }
    ...
}

A 15-second timeout is set for both fsockopen() to establish the connection and for the data stream to respond to requests once connected. Timeouts are crucial for network calls because servers might become unresponsive, and our code needs to handle such situations.

The initial line from the stream, usually a greeting or connection confirmation, is retrieved and discarded. Consult your email service’s documentation to confirm this behavior.

Let’s execute this code to verify the success of init():

1
2
3
4
5
6
7
8
include("imap_driver.php");

// test for init()
$imap_driver = new imap_driver();
if ($imap_driver->init('ssl://imap.gmail.com', 993) === false) {
    echo "init() failed: " . $imap_driver->error . "\n";
    exit;
}

Basic IMAP Syntax

With an active socket connection to our IMAP server, we can start sending commands. Let’s delve into IMAP syntax.

For formal documentation, refer to the Internet Engineering Task Force (IETF) RFC3501. In IMAP interactions, the client typically sends commands, and the server responds, indicating success or failure, often accompanied by the requested data.

The basic command syntax is:

1
line_number command arg1 arg2 ...

The “tag,” or line number, uniquely identifies the command, allowing the server to correlate responses when processing multiple commands simultaneously.

Here’s an example illustrating the LOGIN command:

1
00000001 LOGIN example@gmail.com password

The server may start its response with an “untagged” data response. For example, upon successful login, Gmail sends an untagged response detailing server capabilities and options. Similarly, fetching an email results in an untagged response containing the message body. Regardless, responses always conclude with a “tagged” command completion line. This line identifies the corresponding command’s line number, a completion status indicator, and any additional metadata:

1
line_number status metadata1 metadata2 ...

Gmail’s response to the LOGIN command is shown below:

  • Success:
1
2
3
4
* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS 
COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS

00000001 OK example@gmail.com authenticated (Success)
  • Failure:
1
00000001 NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)

The status can be OK for success, NO for failure, or BAD for invalid commands or syntax errors.

Implementing Basic Commands:

Let’s create a function to send commands to the IMAP server and retrieve the response along with the endline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class imap_driver
{
    private $command_counter = "00000001";
    public $last_response = array();
    public $last_endline = "";

    private function command($command)
    {
        $this->last_response = array();
        $this->last_endline  = "";
        
        fwrite($this->fp, "$this->command_counter $command\r\n");            // send the command
        
        while ($line = fgets($this->fp)) {                                   // fetch the response one line at a time
            $line = trim($line);                                             // trim the response
            $line_arr = preg_split('/\s+/', $line, 0, PREG_SPLIT_NO_EMPTY);  // split the response into non-empty pieces by whitespace
            
            if (count($line_arr) > 0) {
                $code = array_shift($line_arr);                              // take the first segment from the response, which will be the line number
                
                if (strtoupper($code) == $this->command_counter) {
                    $this->last_endline = join(' ', $line_arr);              // save the completion response line to parse later
                    break;
                } else {
                    $this->last_response[] = $line;                          // append the current line to the saved response
                }
                
            } else {
                $this->last_response[] = $line;
            }
        }
        
        $this->increment_counter();
    }

    private function increment_counter()
    {
        $this->command_counter = sprintf('%08d', intval($this->command_counter) + 1);
    }
    ...
}

The LOGIN Command

Now, we can define functions for specific commands that utilize our command() function internally. Let’s write one for the LOGIN command:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class imap_driver
{ 
    ...
    public function login($login, $pwd)
    {
        $this->command("LOGIN $login $pwd");
        if (preg_match('~^OK~', $this->last_endline)) {
            return true;
        } else {
            $this->error = join(', ', $this->last_response);
            $this->close();
            return false;
        }
    }
    ...
}

We can test it as follows (ensure you have an active email account):

1
2
3
4
5
6
...
// test for login()
if ($imap_driver->login('example@gmail.com', 'password') === false) {
    echo "login() failed: " . $imap_driver->error . "\n";
    exit;
}

Gmail prioritizes security and, by default, restricts IMAP access from countries other than the account profile’s country unless configured otherwise. This can be easily rectified by enabling less secure settings in your Gmail account, as explained here.

The SELECT Command

Let’s now explore selecting an IMAP folder for email manipulation. Thanks to our command() method, the syntax resembles LOGIN. We use the SELECT command and provide the folder name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class imap_driver
{
    ...
    public function select_folder($folder)
    {
        $this->command("SELECT $folder");
        if (preg_match('~^OK~', $this->last_endline)) {
            return true;
        } else {
            $this->error = join(', ', $this->last_response);
            $this->close();
            return false;
        }
    }
    ...
}

To test, let’s select the INBOX:

1
2
3
4
5
6
...
// test for select_folder()
if ($imap_driver->select_folder("INBOX") === false) {
    echo "select_folder() failed: " . $imap_driver->error . "\n";
    return false;
}

Implementing Advanced Commands

Let’s examine the implementation of more sophisticated IMAP commands.

The SEARCH Command

Searching for emails within specific date ranges or with particular flags is a common requirement. The SEARCH command accepts search criteria as space-separated arguments. For instance, to retrieve emails since November 20th, 2015:

1
00000005 SEARCH SINCE 20-Nov-2015

A typical response would be:

1
2
* SEARCH 881 882
00000005 OK SEARCH completed

Detailed documentation on search terms can be found here. The SEARCH command output is a space-separated list of email UIDs. A UID is a chronologically ordered, unique identifier for an email within a user’s account, with 1 being the oldest. To implement this command, we simply return the resulting UIDs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class imap_driver
{
    ...
    public function get_uids_by_search($criteria)
    {
        $this->command("SEARCH $criteria");
        
        if (preg_match('~^OK~', $this->last_endline)
        && is_array($this->last_response)
        && count($this->last_response) == 1) {
        
            $splitted_response = explode(' ', $this->last_response[0]);
            $uids              = array();
            
            foreach ($splitted_response as $item) {
                if (preg_match('~^\d+$~', $item)) {
                    $uids[] = $item;                        // put the returned UIDs into an array
                }
            }
            return $uids;
            
        } else {
            $this->error = join(', ', $this->last_response);
            $this->close();
            return false;
        }
    }
    ...
}

Let’s test this by fetching emails from the past three days:

1
2
3
4
5
6
7
8
...
// test for get_uids_by_search()
$ids = $imap_driver->get_uids_by_search('SINCE ' . date('j-M-Y', time() - 60 * 60 * 24 * 3));
if ($ids === false)
{
    echo "get_uids_failed: " . $imap_driver->error . "\n";
    exit;
}

The FETCH Command with BODY.PEEK

Fetching email headers without marking them as SEEN is another frequent task. The IMAP manual states that the appropriate command for this is command for retrieving all or part of an email FETCH. The first argument specifies the desired part, usually BODY, which returns the entire message, including headers, but marks it as SEEN. Alternatively, BODY.PEEK accomplishes the same without marking the message as read.

IMAP syntax requires us to specify the desired email section within square brackets, which is [HEADER] in this case. Consequently, the command becomes:

1
00000006 FETCH 2 BODY.PEEK[HEADER]

We expect a response resembling this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
* 2 FETCH (BODY[HEADER] {438}
MIME-Version: 1.0
x-no-auto-attachment: 1
Received: by 10.170.97.214; Fri, 30 May 2014 09:13:45 -0700 (PDT)
Date: Fri, 30 May 2014 09:13:45 -0700
Message-ID: <CACYy8gU+UFFukbE0Cih8kYRENMXcx1DTVhvg3TBbJ52D8OF6nQ@mail.gmail.com>
Subject: The best of Gmail, wherever you are
From: Gmail Team <mail-noreply@google.com>
To: Example Test <example@gmail.com>
Content-Type: multipart/alternative; boundary=001a1139e3966e26ed04faa054f4
)
00000006 OK Success

To create a function for fetching headers, we need to return the response as a hash structure (key/value pairs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class imap_driver
{ 
    ...
    public function get_headers_from_uid($uid)
    {
        $this->command("FETCH $uid BODY.PEEK[HEADER]");
        
        if (preg_match('~^OK~', $this->last_endline)) {
            array_shift($this->last_response);                  // skip the first line
            $headers    = array();
            $prev_match = '';
            
            foreach ($this->last_response as $item) {
                if (preg_match('~^([a-z][a-z0-9-_]+):~is', $item, $match)) {
                    $header_name           = strtolower($match[1]);
                    $prev_match            = $header_name;
                    $headers[$header_name] = trim(substr($item, strlen($header_name) + 1));
                    
                } else {
                    $headers[$prev_match] .= " " . $item;
                }
            }
            return $headers;
            
        } else {
            $this->error = join(', ', $this->last_response);
            $this->close();
            return false;
        }
    } 
    ...
}

Testing this code involves providing the target message’s UID:

1
2
3
4
5
6
...
// test for get_headers_by_uid
if (($headers = $imap_driver->get_headers_from_uid(2)) === false) {
    echo "get_headers_by_uid() failed: " . $imap_driver->error . "\n";
    return false;
}

Gmail IMAP Extensions

Gmail offers specialized commands that can greatly simplify our tasks. A list of these Gmail IMAP extensions can be found here. Let’s focus on what’s arguably the most useful one: X-GM-RAW. This extension allows using Gmail’s search syntax within IMAP. For example, we can search for emails categorized as Primary, Social, Promotions, Updates, or Forums.

Essentially, X-GM-RAW extends the SEARCH command, allowing us to reuse our existing SEARCH code. We only need to add the X-GM-RAW keyword followed by the criteria:

1
2
3
4
5
6
7
...
// test for gmail extended search functionality
$ids = $imap_driver->get_uids_by_search(' X-GM-RAW "category:primary"');
if ($ids === false) {
    echo "get_uids_failed: " . $imap_driver->error . "\n";
    return false;
}

This code will retrieve all UIDs of emails categorized as “Primary”.

Note: As of December 2015, Gmail sometimes confuses the “Primary” category with the “Updates” category for certain accounts. This known Gmail bug remains unresolved.

Conclusion

You've got mail. Now what? Read how to build a custom IMAP email client in PHP, and check the mail on your terms.

The custom socket approach empowers developers with greater flexibility and control. It allows implementing any command defined in IMAP RFC3501 and provides transparency by eliminating the need to speculate about “behind-the-scenes” operations.

The complete imap_driver class implementation from this article is available for download here. It’s ready for immediate use, and developers can easily extend it by adding new functions or requests to interact with their IMAP server. A debug feature is also included for verbose output.

Licensed under CC BY-NC-SA 4.0