Recursive AI

Extract attachments from email in C#

All onemail recipes

Open the file with Mail.Open, and for each message copy attachment.Open() to a file. The same loop works for .eml, Outlook .msg, winmail.dat, PST stores and mbox files. The program below saves the attachments of three public test files from Apache Tika and Apache POI.

Terminal
dotnet new console -o ExtractAttachments && cd ExtractAttachments
dotnet add package RecursiveAI.OneMail --version 0.2.0-preview.2
curl -LO https://raw.githubusercontent.com/apache/tika/4.2.0/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/resources/test-documents/testRFC822_multipart_attachments.eml
curl -LO https://raw.githubusercontent.com/apache/poi/REL_5_4_1/test-data/hsmf/attachment_test_msg.msg
curl -LO https://raw.githubusercontent.com/apache/poi/REL_5_4_1/test-data/hmef/quick-winmail.dat

Put this in Program.cs and run dotnet run (or dotnet run -- yourfile.eml):

C#
using RecursiveAI.OneMail;
 
// Any message file or mailbox works the same: .eml, .msg, winmail.dat, .pst, mbox
string[] inputs = args.Length > 0 ? args :
[
    "testRFC822_multipart_attachments.eml",
    "attachment_test_msg.msg",
    "quick-winmail.dat",
];
 
foreach (string input in inputs)
{
    string dir = Path.Combine("attachments", Path.GetFileNameWithoutExtension(input));
    Directory.CreateDirectory(dir);
 
    using MailContainer mail = Mail.Open(input);
    foreach (IMailItem msg in mail.Enumerate())
    {
        foreach (IMailAttachment a in msg.Attachments)
        {
            // FileName is what the sender wrote: keep only the name, never a path
            string name = Path.GetFileName(a.FileName ?? "");
            if (name == "") name = "attachment.bin";
            string path = Path.Combine(dir, name);
            for (int i = 2; File.Exists(path); i++)
                path = Path.Combine(dir, $"{i}-{name}");
 
            using (Stream content = a.Open())   // decoded content
            using (FileStream file = File.Create(path))
                content.CopyTo(file);
            Console.WriteLine($"{path}  {new FileInfo(path).Length:N0} bytes");
        }
    }
}
Text
attachments/testRFC822_multipart_attachments/Test TxtA.txt  33 bytes
attachments/testRFC822_multipart_attachments/Test TxtB.txt  33 bytes
attachments/attachment_test_msg/test-unicode.doc  24,064 bytes
attachments/attachment_test_msg/pj1.txt  89 bytes
attachments/quick-winmail/quick.doc  19,968 bytes
attachments/quick-winmail/quick.html  428 bytes
attachments/quick-winmail/quick.pdf  18,638 bytes
attachments/quick-winmail/quick.txt  235 bytes
attachments/quick-winmail/quick.xml  143 bytes

Open() returns the decoded content: base64 and quoted-printable are undone, and Outlook attachments come straight from the file. The five files from quick-winmail.dat are byte-identical to the copies Apache POI keeps beside it.

Things to know

  • Never use the name as a path. FileName is what the sender wrote, and it can hold ../ or a full path. Path.GetFileName keeps only the name; two attachments with the same name don’t overwrite each other.
  • Inline images (pictures the HTML body shows through cid: links) are attachments too, with IsInline set. Skip them with if (a.IsInline) continue;.
  • Attached emails have Embedded set: read that IMailItem the same way to reach the files inside it.
  • winmail.dat inside an email is unpacked for you: the message lists the files in it instead.

Limits

  • A preview. 0.2.0-preview.2: the API can change before 1.0.
  • Size costs a pass. It decodes the content once to measure it, so prefer the file length after copying, as above.
  • Content type from Outlook. MSG and TNEF attachments that don’t store a MIME type report application/octet-stream.

Reading the rest of a message: Parse an EML file, Read an Outlook MSG file. The shared view: Mail.Open and the read view.