3

In Powershell, I'm trying to create a zip file from a large text string, attach it to an email message, and send it along. The trick is that I need to do it without using local storage or disk resources, creating the constructs in memory.

I have two pieces where I'm stuck.

  1. The steps I'm taking below don't write any content to the zip variable or its entry.
  2. How do I attach the zip, once it's complete, to the email?

Because I can't get past the first issue of creating the zip, I haven't been able to attempt to attach the zip to the email.

$strTextToZip = '<html><head><title>Log report</title></head><body><p>Paragraph</p><ul><li>first</li><li>second</li></ul></body></html>'

Add-Type -Assembly 'System.IO.Compression'
Add-Type -Assembly 'System.IO.Compression.FileSystem'

# step 1 - create the memorystream for the zip archive
   $memoryStream = New-Object System.IO.Memorystream
   $zipArchive = New-Object System.IO.Compression.ZipArchive($memoryStream, [System.IO.Compression.ZipArchiveMode]::Create, $true)

# step 2 - create the zip archive's entry for the log file and open it for writing
   $htmlFile = $zipArchive.CreateEntry("log.html", 0)
   $entryStream = $htmlFile.Open()

# step 3 - write the HTML file to the zip archive entry
   $fileStream = [System.IO.StreamWriter]::new( $entryStream )
   $fileStream.Write( $strTextToZip )
   $fileStream.close()

# step 4 - create mail message
   $msg = New-Object Net.Mail.MailMessage($smtp['from'], $smtp['to'], $smtp['subject'], $smtp['body'])
   $msg.IsBodyHTML = $true

# step 5 - add attachment
   $attachment = New-Object Net.Mail.Attachment $memoryStream, "application/zip"
   $msg.Attachments.Add($attachment)

# step 6 - send email
   $smtpClient = New-Object Net.Mail.SmtpClient($smtp['server'])
   $smtpClient.Send($msg)

The streaming in step 3 for the entry doesn't populate the variable. Also, once populated successfully, I'm not sure how to close the streams and keep the content available for the email attachment. Lastly, I think the Add for the Net.Mail.Attachment in step 5 should successfully copy the zip to the message, but any pointers here would be welcome as well.

Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37
Tony
  • 2,658
  • 2
  • 31
  • 46
  • I think you have to `.Close()` or `.Dispose()` the `$entryStream` as well, to flush its internal buffer and actually write the ZIP entry. – zett42 Nov 05 '22 at 09:17
  • See following for MIME format : https://learn.microsoft.com/en-us/previous-versions/office/developer/exchange-server-2010/aa563375(v=exchg.140) – jdweng Nov 05 '22 at 10:16
  • Use a MemoryStream instead of stream writer. Set position of stream to zero after writing and before reading. – jdweng Nov 05 '22 at 10:18
  • @jdweng I'm following the example listed on Microsoft's [ZipArchive.CreateEntry](https://learn.microsoft.com/en-us/dotnet/api/system.io.compression.ziparchive.createentry?view=netframework-4.5) page. I'm sure that part of my issue is the difference in syntax that PowerShell requires. How should I incorporate MemoryStream instead? – Tony Nov 05 '22 at 17:14
  • `application/gzip`? gzip is a different library. – Santiago Squarzon Nov 05 '22 at 17:14
  • I'll post an answer in s bit to show you how to do the first task but I have no idea if `Net.Mail.Attachment` can take a memory stream as argument – Santiago Squarzon Nov 05 '22 at 17:33
  • @Santiago Squarzon, unfortunate typo. Should be zip... I corrected it in the question's code. – Tony Nov 05 '22 at 17:41
  • Here is c# code that you can use as a model to attach a string as a mail attachment which is what Santiago's code is using. – jdweng Nov 05 '22 at 19:03

1 Answers1

1

For the sake of at least giving a proper guidance on the first item, how to create a Zip in Memory without a file. You're pretty close, this is how it's done, older version of Compress-Archive uses a similar logic with a MemoryStream and it's worth noting because of this it has it's 2Gb limitation, see this answer for more details.

using namespace System.Text
using namespace System.IO
using namespace System.IO.Compression
using namespace System.Net.Mail
using namespace System.Net.Mime

Add-Type -Assembly System.IO.Compression, System.IO.Compression.FileSystem

$memStream     = [MemoryStream]::new()
$zipArchive    = [ZipArchive]::new($memStream, [ZipArchiveMode]::Create, $true)
$entry         = $zipArchive.CreateEntry('log.html', [CompressionLevel]::Optimal)
$wrappedStream = $entry.Open()
$writer = [StreamWriter] $wrappedStream
$writer.AutoFlush = $true
$writer.Write(@'
<html>
  <head>
    <title>Log report</title>
  </head>
  <body>
    <p>Paragraph</p>
    <ul>
      <li>first</li>
      <li>second</li>
    </ul>
  </body>
</html>
'@)

$writer, $wrappedStream, $zipArchive | ForEach-Object Dispose

Up until this point, the first question is answered, now $memStream holds your Zip file in memory, if we were to test if this is true (obviously we would need a file, since I don't have an smtp server available for further testing):

$file = (New-Item 'test.zip' -ItemType File -Force).OpenWrite()
$memStream.Flush()
$memStream.WriteTo($file)
# We dispose here for demonstration purposes
# in the actual code you should Dispose as last step (I think, untested)
$file, $memStream | ForEach-Object Dispose

Resulting Zip file would become:

demo

Now trying to answer the 2nd question, this is based on a hunch and I think your logic is sound already, Attachment class has a .ctor that supports a stream Attachment(Stream, ContentType) so I feel this should work properly though I can't personally test it:

$msg = [MailMessage]::new($smtp['from'], $smtp['to'], $smtp['subject'], $smtp['body'])
$msg.IsBodyHTML = $true

# EDIT:
# Based on OP's comments, this is the correct order to follow
# first Flush()
$memStream.Flush()
# then
$memStream.Position = 0
# now we can attach the memstream

# Use `Attachment(Stream, String, String)` ctor instead
# so you can give it a name
$msg.Attachments.Add([Attachment]::new(
    $memStream,
    'nameyourziphere.zip',
    [ContentType]::new('application/zip')
))
$memStream.Close() # I think you can close here then send
$smtpClient = [SmtpClient]::new($smtp['server'])
$smtpClient.EnableSsl = $true
$smtpClient.Send($msg)
$msg, $smtpClient | ForEach-Object Dispose
Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37
  • 1
    Thank you for the thoughtful answer. When I try to export `$memStream` for examination using your export code, it successfully creates a file, but I am unable to use Windows' File Explorer to navigate into the zip. It produces the error _"Compressed (zipped) Folders Error - Windows cannot open the folder. The Compressed (zipped Folder 'C:\temp\test.zip' is invalid."_ 7-Zip is able to see its content and extract the entry, but its "Test archive" option reveals an error: _"Unexpected end of data"_. – Tony Nov 06 '22 at 02:56
  • 1
    @Tony you're totally right, that was my bad for not testing extracting the zip file. The `Dipose` order was off I've updated – Santiago Squarzon Nov 06 '22 at 04:14
  • 1
    Again, thanks for helping me. I don't work with .Net very often, so it was informative to see how you included/abbreviated namespaces in the code. 7-Zip's error response from the validity check led me to think there was content remaining in the zipArchive buffer, like filler bytes at the end of a block. Before your revision, I noticed when the zipArchive was disposed, its MemoryStream was lost. Looks like the $true parameter on the archive instantiation took care of that. Other than that, I'm curious why you switched from a MemoryStream of encoded text to a StreamWriter of the straight text. – Tony Nov 06 '22 at 07:29
  • 1
    @Tony I wasn't aware of that myself just learnt it after your comment yesterday, the `$true` argument on the ctor is needed to leave the stream open but also because after disposing the ziparchive it writes the finalizing bytes (the error we were seeing before). Without it the memory stream is also disposed. As for the stream writer it's just easier the way you're doing than using another memory stream to write the bytes to the zip entry – Santiago Squarzon Nov 06 '22 at 15:38
  • 1
    FYI, `$memStream.Flush()` then `$memStream.Position = 0` need to precede the `Attachments.Add` in order for the zip file to be retrievable from the email. I also added `$smtpClient.Send($msg)` after the SSL command. – Tony Nov 08 '22 at 02:31
  • 1
    @Tony thanks for letting me know, I have updated the answer to reflect this – Santiago Squarzon Nov 08 '22 at 03:06