DEV Community

Marco Caciotti
Marco Caciotti

Posted on

Make Laravel SMTP emails show up in the "Sent" folder (raw IMAP APPEND, no library)

You wired up transactional email in Laravel, Mail::to($user)->send(...) works, the message lands in the recipient's inbox. Then you open the sending account in Roundcube (or Apple Mail, or any IMAP client) and… the Sent folder is empty.

Nothing is broken. This is just how email works — and the fix is a single IMAP command most people have never used: APPEND.

Here's the whole thing, with no extra Composer dependency.

Why SMTP send ≠ a copy in "Sent"

Sending and storing are two different protocols:

  • SMTP hands your message to a mail submission agent, which relays it to the recipient. That's it. SMTP has no concept of your mailboxes.
  • IMAP is where your folders (Inbox, Sent, Drafts…) actually live.

When you send from a desktop client, the client quietly drops a copy into Sent over IMAP. Your Laravel app isn't doing that step, so there's no copy to show.

Two "solutions" that look right and aren't:

  • BCC yourself — the copy arrives in Inbox, not Sent, and every message shows you as a recipient. Ugly.
  • A Sieve rule — Sieve filters incoming mail. Outgoing submission never passes through it.

The correct move is to do what the desktop client does: after sending, APPEND a copy to the Sent folder over IMAP.

Step 1 — get the raw MIME of the message you just sent

You want to store exactly what went out — same headers, same body, same attachments. Laravel's Mail::...->send() returns a SentMessage, and you can pull the full RFC 822 string out of it:

$sent = Mail::to($lead->email)->send($mailable);

$raw = $sent
    ?->getSymfonySentMessage()
    ?->getOriginalMessage()
    ?->toString();
Enter fullscreen mode Exit fullscreen mode

$raw is now the complete message (From:, To:, Subject:, MIME parts, attachments — everything). Under Mail::fake() it's null, so guard for that.

Step 2 — APPEND it to the Sent folder

APPEND uploads a full message into a mailbox. The command looks like this:

a1 LOGIN "user@example.com" "password"
a2 APPEND "Sent" (\Seen) {SIZE}
<the raw message bytes>
a3 LOGOUT
Enter fullscreen mode Exit fullscreen mode

That {SIZE} is an IMAP literal, and it is the one thing everybody gets wrong.

{SIZE} is the length of the message in octets (bytes), not characters.

If your message contains any multi-byte UTF-8 (an accented subject, an emoji, a name like José), PHP's strlen() is correct because it counts bytes — but mb_strlen() is not. Get this wrong and the server either truncates the message or hangs waiting for bytes that never come.

Here's a dependency-free appender over a raw TLS socket:

final class ImapSentAppender
{
    public function __construct(
        private string $host,
        private int $port,        // 993 for implicit TLS
        private string $user,
        private string $pass,
        private string $folder = 'Sent',
    ) {}

    public static function appendCommand(string $folder, string $raw): string
    {
        $folder = str_replace(['\\', '"'], ['\\\\', '\\"'], $folder);

        // strlen() = bytes, exactly what IMAP wants. Never mb_strlen().
        return 'APPEND "' . $folder . '" (\\Seen) {' . strlen($raw) . '}';
    }

    public function append(string $raw): void
    {
        // Normalise to CRLF line endings — IMAP is strict about this.
        $raw = str_replace("\n", "\r\n", str_replace("\r\n", "\n", $raw));

        $ctx = stream_context_create(['ssl' => [
            'verify_peer' => false, 'verify_peer_name' => false, // self-signed Dovecot? use a real cert in prod
        ]]);
        $fp = stream_socket_client(
            "ssl://{$this->host}:{$this->port}", $errno, $errstr, 10,
            STREAM_CLIENT_CONNECT, $ctx
        );
        if (! $fp) {
            throw new \RuntimeException("IMAP connect failed: {$errstr}");
        }
        stream_set_timeout($fp, 10);

        try {
            $this->read($fp);                        // server greeting
            $this->cmd($fp, 'a1', sprintf('LOGIN "%s" "%s"', $this->q($this->user), $this->q($this->pass)));

            // Send the command line with the literal, wait for the "+" continuation.
            fwrite($fp, 'a2 ' . self::appendCommand($this->folder, $raw) . "\r\n");
            if (! str_starts_with(ltrim($this->read($fp)), '+')) {
                throw new \RuntimeException('Server refused APPEND');
            }

            fwrite($fp, $raw . "\r\n");               // now the message itself
            $this->until($fp, 'a2');                  // wait for "a2 OK"

            $this->cmd($fp, 'a3', 'LOGOUT', allowBye: true);
        } finally {
            fclose($fp);
        }
    }

    private function cmd($fp, string $tag, string $line, bool $allowBye = false): void
    {
        fwrite($fp, "{$tag} {$line}\r\n");
        $this->until($fp, $tag, $allowBye);
    }

    private function until($fp, string $tag, bool $allowBye = false): void
    {
        while (($l = fgets($fp)) !== false) {
            if (str_starts_with($l, "{$tag} OK")) return;
            if ($allowBye && str_starts_with($l, '* BYE')) return;
            if (str_starts_with($l, "{$tag} NO") || str_starts_with($l, "{$tag} BAD")) {
                throw new \RuntimeException('IMAP error: ' . trim($l));
            }
        }
        throw new \RuntimeException("No tagged response for {$tag}");
    }

    private function read($fp): string { return (string) fgets($fp); }
    private function q(string $v): string { return str_replace(['\\', '"'], ['\\\\', '\\"'], $v); }
}
Enter fullscreen mode Exit fullscreen mode

Wiring it into a send:

$sent = Mail::to($lead->email)->send($mailable);

$raw = $sent?->getSymfonySentMessage()?->getOriginalMessage()?->toString();

if ($raw !== null) {
    try {
        app(ImapSentAppender::class)->append($raw);
    } catch (\Throwable $e) {
        // Best-effort: the mail already went out. A missing Sent copy
        // must never turn a successful send into a failure.
        report($e);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things that will save you an afternoon

1. It's best-effort. The email is already delivered by the time you APPEND. If IMAP is down, log it and move on — never let the copy step throw and make your controller report a failed send. Hide it behind an interface with a no-op implementation, so environments without IMAP configured just skip it:

interface SentMailboxAppender { public function append(string $raw): void; }

final class NullSentMailboxAppender implements SentMailboxAppender {
    public function append(string $raw): void { /* no-op */ }
}
Enter fullscreen mode Exit fullscreen mode

Bind the real one only when the IMAP host is configured; otherwise bind the null one. Sending keeps working everywhere; the Sent copy is a bonus, not a dependency.

2. CRLF line endings. IMAP wants \r\n. Normalise before you measure the byte length, or your {SIZE} won't match what you actually send.

That's it

No ext-imap, no library — just a socket and three commands. I ran into this building the outreach flow for seoautohub.com (a Laravel app that emails PDF reports from the admin panel): messages were going out fine, but nothing showed in the team's webmail Sent folder. One APPEND after each send and every sent message now lives where you'd expect it.

If you're on ext-imap, imap_append() does the same thing in one call — but it's deprecated in PHP 8.4 and being unbundled, so a tiny socket helper like this is a decent thing to own.

Happy shipping. 📨

Top comments (0)