javascriptphpimap

Javascript interface not downloading IMAP attachments through PHP


I have a webpage in PHP that lets me access an IMAP account. I can fetch the messages fine. But, when I go to download attachments, Firefox opens the download dialog, but immediately after pops an error window, saying: "W227LcEc.png.part could not be saved, because the source file could not be read." And it doesn't let me click the "Save" button in the download dialog.

However, I checked the Network tab of the Developer Tools. The file was correctly received (return code 200), the size is exactly as expected, and in the Response tab I can see the contents of the file, encoded in Base64. If I take this content, and save into a text file, decode Base64, I have a perfectly working file.

I've googled, found advices about header directives, but to no avail to solve the problem.

I'm including only relevant part of codes.

This is the HTML:

<div onclick="download('1234','ABCEDF')">clinical-guidelines-2024-en.pdf</div>

In this example, '1234' would be a unique identifier to a specific email, and 'ABCEDF' a unique identifier to its attachment (in this example, the mencioned PDF).

Now, the javascript code:

function download(message_id,attachment_id) {
    var link_el = document.createElement("A");
    link_el.href = 'https://my.server.com/webservice.php?message_id='+message_id+'&attachment_id='+attachment_id;
    link_el.click();
}

And this is the relevant part of the PHP code in webservice.php:

$struct = imap_fetchstructure($imap_instance,$message_id,FT_UID);
...
// Then I process $struct, loop through $struct->parts, until I find the correct attachment
...
// When attachment is found:
$type = $struct->parts[$i]->type;
$subtype = strtolower($struct->parts[$i]->subtype);
$filesize = $struct->parts[$i]->bytes;
$encoding = $struct->parts[$i]->encoding;
$attachment_partno = $i + 1;
$data = imap_fetchbody($imap_instance,$message_id,$attachment_partno,FT_UID | FT_PEEK);
if ($encoding == 4) {
    $data = quoted_printable_decode($data);
} elseif ($encoding == 3) {
    $data = base64_decode($data);
}
$mimetypes = array(0 => 'text',1 => 'multipart',2 => 'message',3 => 'application',4 => 'audio',5 => 'image',6 => 'video',7 => 'model',8 => 'other');
header('Content-Type: '.$mimetypes[$type].'/'.$subtype); 
// Force the browser to download the file as an attachment
// The $filename variable is processed from the PARAMETERS and DPARAMETERS values, and decoded with imap_mime_header_decode(). I'm omitting all this for brevity, since this part is working all right.
header('Content-Disposition: attachment; filename="'.$filename.'"');
// Specify the file size for download progress indicators
header('Content-Length: '.$filesize);
// Prevent caching of the file
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Expires: 0');
echo $data;

Clicking on the link, a request to the server is correctly made, and the answer also is correct. Here are the response headers:

HTTP/2 200 
date: Tue, 18 Nov 2025 15:07:57 GMT
content-type: application/pdf
content-length: 408416
access-control-allow-origin: UNSET
content-disposition: attachment; filename="clinical-guidelines-2024-en.pdf"
cache-control: must-revalidate, post-check=0, pre-check=0
pragma: public
expires: 0
strict-transport-security: max-age=63072000; includeSubDomains; preload
permissions-policy: accelerometer=(), ambient-light-sensor=(), attribution-reporting=(), autoplay=(), bluetooth=(), browsing-topics=(), camera=(), compute-pressure=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), otp-credentials=(), payment=(), picture-in-picture=(), publickey-credentials-create=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), storage-access=(), usb=(), web-share=(), window-management=(), xr-spatial-tracking=(), aria-notify=(), captured-surface-control=(), cross-origin-isolated=(), deferred-fetch=(), deferred-fetch-minimal=(), on-device-speech-recognition=(), summarizer=(), wildcards=(), interest-cohort=()
x-dns-prefetch-control: off
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
content-security-policy: object-src 'none'; form-action 'self'; frame-ancestors 'self';
X-Firefox-Spdy: h2

My platform is Windows 10, and I'm using Firefox 144.0 64-bit.

Any help is appreciated!


Solution

  • The problem lays in an incorrect mention to the attachment's size. Effectively, I'm getting the size from the headers returned by the IMAP server:

    $filesize = $struct->parts[$i]->bytes;
    

    Then, I fetch the data from the server:

    $data = imap_fetchbody($imap_instance,$message_id,$attachment_partno,FT_UID | FT_PEEK);
    

    But, following that, I'm processing the data:

    $data = base64_decode($data);
    

    Well, this process, decoding from Base64, obviously changes the size of the data, which no longer matches the initial size.

    Moreover, Base64 is roughly 33% larger in size than the plain data; so, if I feed the browser with the former size, it will expect a larger file; when the file transfer concludes, resulting in a smaller file, the browser assumes a network error, and aborts, sometimes without logging any error in the Network tab.

    So, the solution is simply to change the line that sets the 'Content-Length' header. Instead of:

    header('Content-Length: '.$filesize);
    

    I must use:

    header('Content-Length: '.strlen($data));
    

    Now, all is well and working. Note the response headers:

    HTTP/2 200 
    date: Tue, 18 Nov 2025 15:20:27 GMT
    content-type: application/pdf
    content-length: 298458
    access-control-allow-origin: UNSET
    content-disposition: attachment; filename="clinical-guidelines-2024-en.pdf"
    cache-control: must-revalidate, post-check=0, pre-check=0
    pragma: public
    expires: 0
    strict-transport-security: max-age=63072000; includeSubDomains; preload
    permissions-policy: accelerometer=(), ambient-light-sensor=(), attribution-reporting=(), autoplay=(), bluetooth=(), browsing-topics=(), camera=(), compute-pressure=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), otp-credentials=(), payment=(), picture-in-picture=(), publickey-credentials-create=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), storage-access=(), usb=(), web-share=(), window-management=(), xr-spatial-tracking=(), aria-notify=(), captured-surface-control=(), cross-origin-isolated=(), deferred-fetch=(), deferred-fetch-minimal=(), on-device-speech-recognition=(), summarizer=(), wildcards=(), interest-cohort=()
    x-dns-prefetch-control: off
    referrer-policy: strict-origin-when-cross-origin
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    content-security-policy: object-src 'none'; form-action 'self'; frame-ancestors 'self';
    X-Firefox-Spdy: h2
    

    Basically, only the 'content-length' changed, from 408416 to 298458.

    Now, the file downloads normally.

    Please note that you could also simply omit the 'Content-Length' header, although in this case the download progress indicators wouldn't work.