2

I am trying to build an error-handling function that will do the following:

  1. Generate an e-mail message (System.Net.Mail.MailMessage object) with an attachment
  2. Save that MailMessage to a file on disk (.eml)
  3. Open the saved file in a new mail window with the user's mail client so that they can send it (or not if they so choose)

I'm doing all of this so that I don't have to code in a specific SMTP server and set of credentials (I've had no luck in figuring out how to use other methods for a "generic" system like I want). What I'm trying to do is basically emulate the functionality of a mailto: link on an HTML page but from a desktop application and with one or more attachments.

I found this extension method on CodeProject (Adding Save() functionality to System.Net.Mail.MailMessage) for saving the Net.Mail.MailMessage object to a .eml file (converted to VB.NET):

<Extension()>
Public Sub Save(ByVal OriginalMessage As Net.Mail.MailMessage, ByVal EMLFileName As String, ByVal Optional AddUnsentHeader As Boolean = True)
    Try
        Using EMLFileStream As IO.FileStream = IO.File.Open(EMLFileName, IO.FileMode.Create)
            If AddUnsentHeader Then
                ' Add the "unsent header" so that the message can be opened in "edit" mode.
                Using EMLWriter As New IO.BinaryWriter(EMLFileStream)
                    EMLWriter.Write(Text.Encoding.UTF8.GetBytes("X-Unsent: 1" & Environment.NewLine))
                End Using
            End If

            Dim MailAssembly As Assembly = New Net.Mail.SmtpClient().GetType().Assembly
            Dim MailWriterType As Type = MailAssembly.[GetType]("System.Net.Mail.MailWriter")
            
            ' Get reflection info for MailWriter constructor
            Dim MailWriterContructor As ConstructorInfo = MailWriterType.GetConstructor(BindingFlags.Instance Or BindingFlags.NonPublic, Nothing, New Type() {GetType(IO.Stream)}, Nothing)

            ' Construct the MailWriter object with our FileStream
            Dim MailWriter As Object = MailWriterContructor.Invoke(New Object() {EMLFileStream})

            ' Get reflection info for Send() method on MailMessage
            Dim SendMethod As MethodInfo = New Net.Mail.MailMessage().GetType().GetMethod("Send", BindingFlags.Instance Or BindingFlags.NonPublic)

            ' Call method passing in MailWriter
            SendMethod.Invoke(OriginalMessage, BindingFlags.Instance Or BindingFlags.NonPublic, Nothing, New Object() {MailWriter, True, True}, Nothing)

            ' Finally get reflection info for Close() method on our MailWriter
            Dim CloseMethod As MethodInfo = MailWriter.GetType().GetMethod("Close", BindingFlags.Instance Or BindingFlags.NonPublic)

            ' Call close method
            CloseMethod.Invoke(MailWriter, BindingFlags.Instance Or BindingFlags.NonPublic, Nothing, New Object() {}, Nothing)
        End Using
    Catch ex As Exception
        ' DO SOMETHING
    End Try
End Sub

However, when I call this extension method with a MailMessage object that has an attachment (a compressed ZIP file), it throws an exception when it tries to execute the SendMethod.Invoke() line. The .Message property from the inner exception states Cannot access a closed file. The .eml file is created on disk, but it's completely empty.

I also tried using a variation posted in the comments of that thread that uses a MemoryStream instead of the FileStream object at the top of the method and then writing that out with the File.WriteAllText() method, but it throws a similar exception at the same point (before it can hit the WriteAllText() method) with an inner exception .Message value of Cannot access a closed Stream.

I'm calling the extension method after building my MailMessage like this (redacted for the sake of brevity):

Private Sub SendExceptionLog(ByVal LogFile As IO.FileInfo)
    Dim TempMailMessageFile As String = $"D:\Temp\Error_{Now.ToString("yyyyMMddHHmmss")}.eml"
    Dim DesktopScreenshot As IO.FileInfo = Nothing

    If BaseApplicationSettings.LogLevel >= LoggingLevel.TRACE Then
        DesktopScreenshot = TakeDesktopScreenshot()
    End If

    Using Email As New MailMessage
        Dim AttachmentList As List(Of IO.FileInfo) = Nothing
        Dim Sender As New MailAddress("sender@domain.com", "My Application")
        Dim Message As String = String.Empty

        With Email
            .Subject = "Error in MyApp"
            .From = Sender
            .IsBodyHtml = True
            .To.Add(New MailAddress("developers@domain.com", "Developers"))

            Dim MyApp As Reflection.Assembly = Reflection.Assembly.LoadFrom("Common Library.dll")

            Using ReportStream As IO.Stream = MyApp.GetManifestResourceStream("MyApp.ErrorReport.html")
                If ReportStream IsNot Nothing Then
                    Using BodyReader As New System.IO.StreamReader(ReportStream)
                        Message = BodyReader.ReadToEnd
                    End Using
                End If
            End Using

            Message = Message.Replace("$DateTime$", Format(Now, "d MMMM yyyy - h:mm tt"))
            .Body = Message

            If LogFile IsNot Nothing Then
                AttachmentList = New List(Of IO.FileInfo)
                AttachmentList.Add(LogFile)

                If DesktopScreenshot IsNot Nothing Then
                    AttachmentList.Add(DesktopScreenshot)
                End If
            ElseIf DesktopScreenshot IsNot Nothing Then
                AttachmentList = New List(Of IO.FileInfo)
                AttachmentList.Add(DesktopScreenshot)
            End If

            If AttachmentList IsNot Nothing AndAlso AttachmentList.Count > 0 Then
                Dim AttachmentZIP As IO.FileInfo = ZIPArchive.CreateZIPArchive(AttachmentList, $"ErrorLog_{Now.ToString("yyyyMMddHHmmss")}.zip", New IO.DirectoryInfo($"{BasePaths.BaseTempPath}\Ichthus\"))

                .Attachments.Clear()
                .Attachments.Add(New Attachment(AttachmentZIP.FullName))
            End If
        End With

        Email.Save(TempMailMessageFile)
        Process.Start(TempMailMessageFile)
    End Using

    If DesktopScreenshot IsNot Nothing Then
        If IO.File.Exists(DesktopScreenshot.FullName) Then
            IO.File.Delete(DesktopScreenshot.FullName)
        End If
    End If
End Sub

I also tried calling the Save() extension method from a MailMessage object without any attachments, but it resulted in the exact same exception as above.

Trying to find a way to achieve my goals, I also tried the solution offered in this answer from another StackOverflow question: How to save MailMessage object to disk as *.eml or *.msg file (I just threw the Gmail SMTP relay server in there as a placeholder, but I'm not really sure it matters all that much).

Dim client As New System.Net.Mail.SmtpClient("smtp-relay.gmail.com") With {
    .DeliveryMethod = Net.Mail.SmtpDeliveryMethod.SpecifiedPickupDirectory,
    .PickupDirectoryLocation = EMLFileName}
client.Send(OriginalMessage)

This method successfully saves the message including the attachment to disk without error, but I have two "issues" with that method:

  1. It doesn't allow me to specify the file name, so I'd have to go "searching" for the file in order to have my code open it with the Process.Start call.
  2. It's not saved as "unsent", so the user wouldn't be able to simply click the Send button from their mail client.

I could probably find a way to work around each of these issues, but I'm guessing it would probably involve a significant part of the code from the first method I listed, so it seems rather pointless if I'm just going to end up doing both.

I'm not sure what File (or Stream) is causing the exception during the method invocation, but I assume (based on my testing with the MemoryStream) that it's the file created by the FileStream object. As I said, the .eml file is created on disk, but completely empty - no subject, body, or attachments.

MY ENVIRONMENT:

Microsoft Visual Studio Community 2019
Version 16.11.11

PROJECT INFO

Class Library
.NET Framework 4.7.2
x64 Target CPU

Please let me know if I forgot to include any details that might help to diagnose why this code is throwing this exception.

G_Hosa_Phat
  • 976
  • 2
  • 18
  • 38
  • Why replicate Mailto when you can just use it via Process.Start("mailto:.....") – Hursey Mar 31 '22 at 02:29
  • @Hursey Because AFAIK and have been able to test, `mailto:` doesn't work with attachments. Of course, if you've got a solution that *does*, I'd be glad to hear it! – G_Hosa_Phat Mar 31 '22 at 02:48

1 Answers1

0

I've found a solution that apparently achieves my stated goals, but it uses a completely different method than what I posted in my original question. Instead of using Reflection to expose and construct an internal MailWriter object and then invoke the internal Send() method, I am using the old Microsoft CDO library to construct a CDO.Message object with all of the properties from the "original" System.Net.Mail.MailMessage object. The extension then saves the CDO.Message object to disk using the .GetStream.SaveToFile() method before returning to my SendExceptionLog() method where the .eml file is opened with the user's mail client as unsent with Process.Start().

My testing so far hasn't encountered any errors, although it does rely on late-binding, so you might need to mark the module with Option Strict Off to avoid warnings and such. Here's the code I'm currently using:

<Extension()>
Public Sub SaveCDO(ByVal OriginalMessage As Net.Mail.MailMessage, ByVal EMLFileName As String)
    Dim CDOMessage As Object = CreateObject("CDO.Message")

    With OriginalMessage
        Dim RecipientBuffer As String = String.Empty

        CDOMessage.Subject = .Subject
        CDOMessage.From = .From.DisplayName & " <" & .From.Address & ">"

        For Each ToAddress As Net.Mail.MailAddress In .To
            RecipientBuffer = RecipientBuffer & ToAddress.DisplayName & " <" & ToAddress.Address & ">; "
        Next

        If Not String.IsNullOrEmpty(RecipientBuffer) Then
            CDOMessage.To = RecipientBuffer
            RecipientBuffer = String.Empty
        End If

        For Each ToAddress As Net.Mail.MailAddress In .CC
            RecipientBuffer = RecipientBuffer & ToAddress.DisplayName & " <" & ToAddress.Address & ">; "
        Next

        If Not String.IsNullOrEmpty(RecipientBuffer) Then
            CDOMessage.CC = RecipientBuffer
            RecipientBuffer = String.Empty
        End If

        For Each ToAddress As Net.Mail.MailAddress In .Bcc
            RecipientBuffer = RecipientBuffer & ToAddress.DisplayName & " <" & ToAddress.Address & ">; "
        Next

        If Not String.IsNullOrEmpty(RecipientBuffer) Then
            CDOMessage.BCC = RecipientBuffer
            RecipientBuffer = String.Empty
        End If

        Dim FileNames = .Attachments.[Select](Function(a) a.ContentStream).OfType(Of IO.FileStream)().[Select](Function(fs) fs.Name)

        For Each File As String In FileNames
            CDOMessage.AddAttachment(File)
        Next

        CDOMessage.TextBody = .Body
        CDOMessage.HTMLBody = .Body
        CDOMessage.Fields("urn:schemas:mailheader:x-unsent") = 1
        CDOMessage.Fields.Update

        Try
            'SAVE THE CDO.Messge OBJECT TO DISK AS A FILE
            CDOMessage.GetStream.Flush()
            CDOMessage.GetStream.SaveToFile(EMLFileName)
        Catch CDOex As Exception
            Dim ex As New Exception("An error occurred saving the CDO.Message object to disk", CDOex)

            ex.Data.Add("Sender", CDOMessage.From.ToString)
            ex.Data.Add("To Recipient(s)", CDOMessage.To.ToString)
            ex.Data.Add("CC Recipient(s)", CDOMessage.CC.ToString)
            ex.Data.Add("BCC Recipient(s)", CDOMessage.BCC.ToString)
            ex.Data.Add("Attachment Count", FileNames.Count)
            Throw ex
        Finally
            CDOMessage = Nothing
            Marshal.ReleaseComObject(CDOMessage)
            GC.Collect()
            GC.WaitForPendingFinalizers()
            GC.Collect()
        End Try
    End With
End Function

It could probably use a little "tweaking" here and there - e.g., perhaps it should be a Function returning Boolean to indicate success, or a FileInfo object to make it easier to validate whether or not the method was successful - but, for now, this seems to be producing the exact results I was hoping for. However, since this method has nothing to do with the method I explicitly asked about in my original question and utilizes yet another library, I'll leave it as unanswered for now.

G_Hosa_Phat
  • 976
  • 2
  • 18
  • 38