C# 中的 Outlook 拖放






4.93/5 (89投票s)
如何将多个 Outlook 邮件或邮件附件拖放到 C# WinForm 中。
引言
前几天公司有个项目需要将任意数量的 Outlook 邮件或邮件附件拖放到 WinForms 应用程序中……我心想这很简单,这一定是个常见问题。我可以直接去 CodeProject 找个例子,稍微修改一下就能快速搞定。现在,既然您在这里,我相信您一定知道关于这个主题的信息非常少,所以我决定为这个小范围的知识库贡献一个完整的示例,介绍如何在不使用 Outlook 对象模型的情况下,将邮件项或附件从 Outlook 拖放到 WinForms 应用程序中。
我将跳过讲解创建窗体、允许拖放等在互联网上随处可见的内容,只专注于让 Outlook 拖放生效的代码。如果您觉得迷茫,请去找另一篇关于更基础拖放信息的文章,先实现一个功能完整的拖放应用,然后再回来修改我的代码。
Using the Code
当我开始编写代码时,我决定最简单的方法是将此功能集成到现有应用程序中,那就是创建一个新类,实现通常在拖放到 WinForm 时提供的 IDataObject
接口。新类将捕获所有对 Outlook 特定数据格式的调用,并将所有其他调用传递给原始 IDataObject
。您可以在下面的 DragDrop
事件处理程序中看到使用该类的简单方法。FileGroupDescriptor
格式返回一个包含每个文件名的字符串数组,而不是您自己处理时习惯的 MemoryStream
,而 FileContents
则返回一个包含每个文件二进制内容的 MemoryStream
数组。
private void Form1_DragDrop(object sender, DragEventArgs e)
{
//wrap standard IDataObject in OutlookDataObject
OutlookDataObject dataObject = new OutlookDataObject(e.Data);
//get the names and data streams of the files dropped
string[] filenames = (string[])dataObject.GetData("FileGroupDescriptor");
MemoryStream[] filestreams = (MemoryStream[])dataObject.GetData("FileContents");
for (int fileIndex = 0; fileIndex < filenames.Length; fileIndex++)
{
//use the fileindex to get the name and data stream
string filename = filenames[fileIndex];
MemoryStream filestream = filestreams[fileIndex];
//save the file stream using its name to the application path
FileStream outputStream = File.Create(filename);
filestream.WriteTo(outputStream);
outputStream.Close();
}
}
理解代码
要理解上面的 OutlookDataObject
类是如何获取文件信息的,有两点需要注意。第一,文件名信息是从 Outlook 返回的 MemoryStream
中获取的,实际上这是 FILEGROUPDESCRIPTORA
或 FILEGROUPDESCRIPTORW
结构的表示。第二,要获取文件内容,您需要指定一个索引来获取除第一个文件之外的任何文件,而标准的 IDataObject
并没有直接暴露这个功能。所有这些将在下面详细解释。
获取文件名
IDataObject
在 FileGroupDescriptor
和 FileGroupDescriptorW
格式中返回的文件详细信息有两个版本,分别对应 FILEGROUPDESCRIPTORA
和 FILEGROUPDESCRIPTORW
结构。在本文中,我将重点介绍 FileGroupDescriptor
格式,这是 ASCII 版本;FileGroupDescriptorW
(W 代表 wide)是 Unicode 版本,当处理非 ASCII 文件名时您需要使用它,但处理方式是相同的。
//use the IDataObject to get the FileGroupDescriptor as a MemoryStream
MemoryStream fileGroupDescriptorStream = (MemoryStream)e.Data.GetData("FileGroupDescriptor");
您看到的大多数示例都涉及获取上面的 MemoryStream
,并将索引 76 之后的每个非空字节转换为字符并将其附加到字符串。虽然这对于单个文件拖放来说足够了,但当拖放多个文件时,情况会变得棘手。正确的方法是获取返回的字节,并将其转换为 FILEGROUPDESCRIPTORA
结构,该结构包含一个项计数和一个 FILEDESCRIPTORA
结构数组,后者包含文件详细信息。这些结构的定义如下。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public sealed class FILEGROUPDESCRIPTORA
{
public uint cItems;
public FILEDESCRIPTORA[] fgd;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public sealed class FILEDESCRIPTORA
{
public uint dwFlags;
public Guid clsid;
public SIZEL sizel;
public POINTL pointl;
public uint dwFileAttributes;
public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
public uint nFileSizeHigh;
public uint nFileSizeLow;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string cFileName;
}
基础知识讲完后,让我们开始编写代码,将返回的 MemoryStream
实际转换为可用的东西,例如文件名字符串数组。这涉及到将原始字节放入非托管内存,并使用 Marshal.PtrToStructure
将其转换回结构。代码中有一些额外的封送处理,因为 FILEGROUPDESCRIPTORA
结构的 fgd
数组不会被填充,因为 PtrToStructure
方法无法处理可变长度数组。
//use IDataObject to get FileGroupDescriptor
//as a MemoryStream and copy into a byte array
MemoryStream fgdStream =
(MemoryStream)e.Data.GetData("FileGroupDescriptor");
byte[] fgdBytes = new byte[fgdStream.Length];
fgdStream.Read(fgdBytes, 0, fgdBytes.Length);
fgdStream.Close();
//copy the file group descriptor into unmanaged memory
IntPtr fgdaPtr = Marshal.AllocHGlobal(fgdBytes.Length);
Marshal.Copy(fgdBytes, 0, fgdaPtr, fgdBytes.Length);
//marshal the unmanaged memory to a FILEGROUPDESCRIPTORA struct
object fgdObj = Marshal.PtrToStructure(fgdaPtr,
typeof(NativeMethods.FILEGROUPDESCRIPTORA));
NativeMethods.FILEGROUPDESCRIPTORA fgd =
(NativeMethods.FILEGROUPDESCRIPTORA)fgdObj;
//create a array to store file names in
string[] fileNames = new string[fgd.cItems];
//get the pointer to the first file descriptor
IntPtr fdPtr = (IntPtr)((int)fgdaPointer + Marshal.SizeOf(fgdaPointer));
//loop for the number of files acording to the file group descriptor
for(int fdIndex = 0;fdIndex < fgd.cItems;fdIndex++)
{
//marshal the pointer to the file descriptor as a FILEDESCRIPTORA struct
object fdObj = Marshal.PtrToStructure(fdPtr,
typeof(NativeMethods.FILEDESCRIPTORA));
NativeMethods.FILEDESCRIPTORA fd = (NativeMethods.FILEDESCRIPTORA)fdObj;
//get file name of file descriptor and put in array
fileNames[fdIndex] = fd.cFileName;
//move the file descriptor pointer to the next file descriptor
fdPtr = (IntPtr)((int)fdPtr + Marshal.SizeOf(fd));
}
此时,我们已经将 MemoryStream
转换为一个字符串数组,其中包含每个拖放文件的名称,这比之前容易处理得多。Outlook 邮件会以其主题名加上 ".msg" 后缀的形式显示,而对于 Outlook 邮件附件,则显示附件的文件名。
获取文件内容
文件内容位于 FileContents
格式之后。如果您拖放单个附件,则默认的 IDataObject
会按预期工作,并返回一个包含该文件数据的 MemoryStream
。由于多种原因,当拖放多个附件或 Outlook 电子邮件消息时,情况会变得更复杂。多个附件会造成问题,因为操作系统为拖放数据调用的允许指定索引,但 C# 对 IDataObject
的实现并未直接暴露此功能。邮件消息也是一个问题,因为操作系统调用返回一个 IStorage
,它是一种复合文件类型,同样,C# 对 IDataObject
的实现也无法处理这种类型的返回,因此您会得到一个 null
。
指定索引
要访问多个拖放文件的内容,需要指定一个索引来指示需要哪个文件的内容。默认的 IDataObject
不允许这样做,但可以将其转换为 COM IDataObject
,它将接受一个 FORMATETC
结构,该结构有一个索引属性,可以设置为指定所需的文件内容。
//cast the default IDataObject to a com IDataObject
System.Runtime.InteropServices.ComTypes.IDataObject comDataObject;
comDataObject = (System.Runtime.InteropServices.ComTypes.IDataObject)e.Data;
//create a FORMATETC struct to request the data with from the com IDataObject
FORMATETC formatetc = new FORMATETC();
formatetc.cfFormat = (short)DataFormats.GetFormat(format).Id;
formatetc.dwAspect = DVASPECT.DVASPECT_CONTENT;
formatetc.lindex = 0; //zero based index to retrieve
formatetc.ptd = new IntPtr(0);
formatetc.tymed = TYMED.TYMED_ISTREAM | TYMED.TYMED_ISTORAGE | TYMED.TYMED_HGLOBAL;
//create STGMEDIUM to output request results into
STGMEDIUM medium = new STGMEDIUM();
//using the com IDataObject interface get the data using the defined FORMATETC
comDataObject.GetData(ref formatetc, out medium);
如上例所示,通过更改 FORMATETC
结构中的 lindex
属性的值,我们可以更改要检索的文件内容的索引。调用的结果存储在 STGMEDIUM
结构中;它在 unionmember
属性中包含指向实际结果的指针,并在 tymed
属性中包含结果的类型。STGMEDIUM
提供三种返回类型,每种类型在下面都有解释。
流结果 (TYMED_ISTREAM)
如果 STGMEDIUM
的 tymed
属性是 TYMED_ISTREAM
,则结果是一个流。这通常由默认的 IDataObject
处理,但在使用 COM IDataObject
时,需要重新编写处理代码。
//marshal the returned pointer to a IStream object
IStream iStream = (IStream)Marshal.GetObjectForIUnknown(medium.unionmember);
Marshal.Release(medium.unionmember);
//get the STATSTG of the IStream to determine how many bytes are in it
iStreamStat = new System.Runtime.InteropServices.ComTypes.STATSTG();
iStream.Stat(out iStreamStat, 0);
int iStreamSize = (int)iStreamStat.cbSize;
//read the data from the IStream into a managed byte array
byte[] iStreamContent = new byte[iStreamSize];
iStream.Read(iStreamContent, iStreamContent.Length, IntPtr.Zero);
//wrapped the managed byte array into a memory stream
Stream filestream = new MemoryStream(iStreamContent);
存储结果 (TYMED_ISTORAGE)
如果 STGMEDIUM
的 tymed
属性是 TYMED_ISTORAGE
,则结果是一个存储,它是一种复合文件类型。这比流更复杂一些,因为它需要复制到一个内存后备的 IStorage
中,然后才能从后备内存存储中读取其数据。
NativeMethods.IStorage iStorage = null;
NativeMethods.IStorage iStorage2 = null;
NativeMethods.ILockBytes iLockBytes = null;
System.Runtime.InteropServices.ComTypes.STATSTG iLockBytesStat;
try
{
//marshal the returned pointer to a IStorage object
iStorage = (NativeMethods.IStorage)
Marshal.GetObjectForIUnknown(medium.unionmember);
Marshal.Release(medium.unionmember);
//create a ILockBytes (unmanaged byte array)
iLockBytes = NativeMethods.CreateILockBytesOnHGlobal(IntPtr.Zero, true);
//create a IStorage using the ILockBytes
//(unmanaged byte array) as a backing store
iStorage2 = NativeMethods.StgCreateDocfileOnILockBytes(iLockBytes,
0x00001012, 0);
//copy the returned IStorage into the new memory backed IStorage
iStorage.CopyTo(0, null, IntPtr.Zero, iStorage2);
iLockBytes.Flush();
iStorage2.Commit(0);
//get the STATSTG of the ILockBytes to determine
//how many bytes were written to it
iLockBytesStat = new System.Runtime.InteropServices.ComTypes.STATSTG();
iLockBytes.Stat(out iLockBytesStat, 1);
int iLockBytesSize = (int)iLockBytesStat.cbSize;
//read the data from the ILockBytes
//(unmanaged byte array) into a managed byte array
byte[] iLockBytesContent = new byte[iLockBytesSize];
iLockBytes.ReadAt(0, iLockBytesContent, iLockBytesContent.Length, null);
//wrapped the managed byte array into a memory stream
Stream filestream = new MemoryStream(iStreamContent);
}
finally
{
//release all unmanaged objects
Marshal.ReleaseComObject(iStorage2);
Marshal.ReleaseComObject(iLockBytes);
Marshal.ReleaseComObject(iStorage);
}
HGlobal 结果 (TYMED_HGLOBAL)
如果 STGMEDIUM
的 tymed
属性是 TYMED_HGLOBAL
,则结果存储在 HGlobal
中。对于 Outlook 拖放而言,这种类型不应返回,但为了完整性起见,我使用了一些反射来让原始 IDataObject
类处理它。
//get the internal ole dataobject and its GetDataFromHGLOBLAL method
BindingFlags bindingFlags = BindingFlags.NonPublic | BindingFlags.Instance;
FieldInfo innerDataField =
e.Data.GetType().GetField("innerData", bindingFlags);
IDataObject oleDataObject =
(System.Windows.Forms.IDataObject)innerDataField.GetValue(e.Data);
MethodInfo getDataFromHGLOBLALMethod =
oleDataObject.GetType().GetMethod("GetDataFromHGLOBLAL", bindingFlags);
getDataFromHGLOBLALMethod.Invoke(oleDataObject,
new object[] { format, medium.unionmember });
结论
好了,希望这些内容能帮到一些人。我还有一些其他的 Outlook 小技巧,我将写成文章介绍;一个是如何在不使用对象模型的情况下提取和保存邮件附件,另一个是如何使用本文中的代码实现 Outlook 邮件和附件到 IE 的拖放(需要适当的安全措施,所以只适用于内网)。
历史
- 2008年7月1日
- 原始文章。