65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF 中的 MIME 消息查看库

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2012 年 6 月 11 日

CPOL

14分钟阅读

viewsIcon

39115

downloadIcon

1807

Microsoft Managed Extensibility Framework (MEF) 插件的源代码和一个简单的演示程序,用于提供电子邮件的可视化显示。

MIME message presenter

1. 引言

电子邮件是交换和保存私人信息最常用的方式之一。虽然用户可以通过 Web 界面在线阅读电子邮件,或者使用许多免费的电子邮件客户端离线阅读,但出于各种原因,有时人们仍然希望有一个可以集成到自己系统中的电子邮件阅读器。例如,一个 WPF 项目可能需要一个免费的电子邮件显示插件,该插件可以处理大量电子邮件的下载、排序、搜索、安全存储和阅读,而这些功能是目前公开可用的在线和离线解决方案都无法提供的。此外,它还需要解析和显示用户可能遇到的绝大多数消息。经过一些调查和互联网搜索,可以得出结论,找不到任何符合需求的产品。是时候自己开发一个了。

当前包包含 Microsoft Managed Extensibility Framework (MEF) 插件的源代码和一个简单的演示程序,用于提供电子邮件的可视化显示。它可以方便地集成到任何基于 WPF 的应用程序中。由于这是初始版本,重点放在了信息表示的完整性上。视觉效果和易用性方面将在后续版本中改进,因此本插件并不侧重于 WPF 的视觉效果。

2. 背景

大多数电子邮件消息都是使用 **Multipurpose Internet Mail Extensions (MIME)** 格式编写的。它打破了原始的基于 ASCII 文本的消息限制,通过 SMTP 传输非 ASCII 字符、二进制数据以及消息的不同视图, all encapsulated within a single ASCII text-based data block。这些消息也很有可能包含一个 HTML 格式的视图部分,以获得更好的演示效果、嵌入媒体以及链接到其他资源的可能性。

基于文本的 MIME 消息表示一个树状结构,其每个节点包含消息的特定部分。节点类型和 ID 用于声明这些消息部分之间的关系。在呈现之前,需要对其进行解析以生成 MIME 实体。现代电子邮件客户端可以生成相当复杂的、具有不同编码的 Mime 消息。因此,需要一个能够完全支持 MIME 格式的优秀解析器。经过一些修改和调整,从 Ivar Lumi 提供的相应 MIME 解析器([点击这里](https://codeproject.org.cn/Articles/16423/LumiSoft-MailServer))中提取了他的大型库。生成的 DLL 已包含在下载包中。

HTML 格式的消息可以使用 WPF 控件(即 `FlowDocument` 对象)进行本地显示。但是,由于其复杂性,要完全重现它们在浏览器中的显示效果需要大量的工作,这超出了我们的目的的合理范围。因此,它们将被显示在一个嵌入在 WPF 控件中的 Web 浏览器中。Web 浏览器不接受内存中的数据作为输入,数据必须通过网络套接字或使用本地文件发送。使用本地文件存在一些风险

  • 它会占用磁盘空间,产生临时数据,并且可能导致大量碎片。
  • 它会暴露更多的安全漏洞并引起隐私泄露的担忧。
  • 这体验不太好。
  • 等等。

因此,决定使用嵌入式 HTTP 服务器将内存中的数据发送到浏览器进行显示。

开发一个功能齐全的 HTTP 服务器可能非常复杂,因为它需要实现完整的 HTTP 协议、处理可伸缩性、可扩展性、并发性、缓存管理等。但是,对于我们单个用户和嵌入式使用场景,只需要实现 HTTP 协议的一部分,关闭缓存,并忽略所有其他问题。实际上,开发起来相当简单,尤其是 .NET 4.0 中甚至还有一个 `HttpListener` 类。

3. 入口点、事件、委托

让我们介绍一个宿主程序可以与该库交互的接口。UI 库不仅暴露 API,还暴露事件和回调(委托)。本节的内容就是为此目的而写的。

当前库的入口点是由 `EMailPresenter` 类型支持的消息正文显示面板。它实现了 `IEMailReader` 接口。

public interface IMessageReader
{
    Uri SourceUri { get; set; }
}

public delegate bool QueryMessageThreadHanlder(string MsgID, out ThreadedMessage StartMsg);

public interface IEMailReader : IMessageReader
{
    QueryMessageThreadHanlder QueryThread
    {
        get;
        set;
    }
}

该接口定义在名为 `IFileSystemVShell` 的独立库中,该库应由宿主程序和当前库共享。下面是实现该接口的 `EMailPresenter` 类的定义顶部部分

namespace CryptoGateway.Documents.Mime
{
    [Export(typeof(IEMailReader))]
    public partial class EMailPresenter : UserControl, IEMailReader
    {
        public Uri SourceUri
        {
            get { return (Uri)GetValue(SourceUriProperty); }
            set
            {
                if (SourceUri == null && value != null || SourceUri != null && value == null
       				|| SourceUri.ToString() != value.ToString())
                    SetValue(SourceUriProperty, value);
                if (prev_uri == value.ToString())
                    return;
                prev_uri = value.ToString();
                if (IsAlreadyExpanded || !IsExpandedViewOpened)
                {
                    if (IsAlreadyExpanded)
                    {
                        if (!ThreadSelection && RootModel.ThreadViewer != null 
                                 && !RootModel.ThreadViewer.IsInThread(value.LocalPath))
                            RefreshThreadPending = true;
                    }
                    DisplayMsg();
                }
                else
                    RootModel.ReaderWindow.SourceUri = value;
            }
        }
        public static readonly DependencyProperty SourceUriProperty =
            DependencyProperty.Register("SourceUri", typeof(Uri), typeof(EMailPresenter), 
			new UIPropertyMetadata(null, (o, e) =>
            {
                (o as EMailPresenter).SourceUri = e.NewValue as Uri;
            }));

        ...
        ...
        ...
        ...
        
    }
}
  • 属性“`[Export(typeof(IEMailReader))]`”将该控件作为插件导出到 MEF 框架中。
  • 属性“`SourceUri`”实现了 `IEMailReader` 中声明的相应接口。宿主程序只需设置一个有效的 Uri,嵌入式 HTTP 服务器就会将目标数据发送到浏览器进行显示。在当前版本的库中,仅处理 `SourceUri` 指向用户本地文件系统上的(MIME 消息)文件的情况。用户可以轻松地扩展它,使其可以从其他源加载,例如从 HTTP 或 FTP 服务器。扩展可以通过修改单个方法来完成,即
  • private void DisplayMsg()
    {
        if (SourceUri == null)
        {
            ClearView();
            return;
        }
        if (File.Exists(SourceUri.LocalPath))
        {
            StreamReader sr = new StreamReader(SourceUri.LocalPath);
            Data.RawText = sr.ReadToEnd();
            sr.Close();
            byte[] bf = Encoding.ASCII.GetBytes(Data.RawText);
            DisplayMsg(bf);
        }
        else
            ClearView();
    }

    在 `EMailPresenter` 类中。这是库中 MIME 消息在解析生成 MIME 实体(树)之前被加载的唯一点。

  • 当库需要要求用户选择文件夹时,会引发事件 `TransCompSelectFolderEvent`。例如,在处理 MIME 附件的 `EMailAttachments` 控件内部。该库不自己提供文件夹选择对话框,而是引发事件让宿主程序决定。这是因为 WPF 没有内置的文件夹选择对话框,因此必须使用自定义的文件夹选择对话框,例如 WinForms 提供的。使当前库依赖于额外的自定义库(如 WinForms)将导致它依赖于不需要的东西。因此,它被委托给宿主环境,用户可以在其中拥有比 WinForms 更喜欢的文件夹选择器,例如在我们的 FileSystem SQLizer 系统中。用户如果想处理该事件,应该在包含插件的视觉树中的某个地方捕获它。
  • `QueryMessageThreadHanlder` 委托。该库应该显示宿主程序提供的一个消息。它不了解当前显示的消息属于哪个消息集。此委托用于查询拥有完整 MIME 消息集的宿主程序,以获取消息线程信息。

4. WPF 控件

用户控件 `EMailPresenter` 有多种外观,具体取决于正在查看的消息以及显示是简略模式还是详细模式。除了某些控件按钮外,它只包含一个 `ContentPresenter` 控件,该控件与在其 Resources 属性定义的资源库中的 `DataTemplate` 进行延迟绑定

<UserControl x:Class="CryptoGateway.Documents.Mime.EMailPresenter"
           xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
           xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
           xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
           xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
           xmlns:loc="clr-namespace:CryptoGateway.Documents.Mime"
           xmlns:res="clr-namespace:CryptoGateway.Documents.Mime.Properties"
           xmlns:avalonedit="http://icsharpcode.net/sharpdevelop/avalonedit"
           mc:Ignorable="d" 
           d:DesignHeight="300" d:DesignWidth="300" Loaded="OnLoaded" 
           Initialized="OnInitialized">
<UserControl.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Resources/ControlStyles.xaml" />
    </ResourceDictionary.MergedDictionaries>
    <DataTemplate x:Key="A">
        <TabControl>
            <TabItem Header="{Binding TextHeaderStr}">
                 <TextBox Text="{Binding PlainText}" IsReadOnly="True" Background="White" 
                     TextWrapping="Wrap" 
                     VerticalScrollBarVisibility="Auto" />
            </TabItem>
            <TabItem Header="{Binding RawHeaderStr}">
                 <ScrollViewer VerticalScrollBarVisibility="Auto" 
                             HorizontalScrollBarVisibility="Disabled" 
                             VerticalAlignment="Top" >
                     <loc:LargeTextBox Text="{Binding RawText}" />
                 </ScrollViewer>
            </TabItem>
            <TabItem Header="{Binding OtherAspectsHeaderStr}">
                 <ScrollViewer VerticalScrollBarVisibility="Auto" 
                             HorizontalScrollBarVisibility="Auto" 
                             VerticalAlignment="Top" >
                     <loc:EMailMetaDetails DataContext="{Binding EntityRoot}"/>
                 </ScrollViewer>
            </TabItem>
        </TabControl>
    </DataTemplate>
    <DataTemplate x:Key="AD">
        <TabControl>
            <TabItem Header="{Binding TextHeaderStr}">
               ....
            </TabItem>
            ...
        </TabControl>
    </DataTemplate>
</UserControl.Resources>   
    ...   
</UserControl>

其中每个外观都由资源键(“A”、“AD”...)标识。库会为要显示的每条消息选择正确的外观。显示包含选项卡页面,在最完整显示中,会显示有关 MIME 消息的以下视图

图:相关消息线程

Dog on fire

图:附件预览
  1. 纯文本视图,如果可用。
  2. HTML 视图,如果可用。
  3. 原始数据视图。它包含原始消息数据,供知识渊博或好奇的用户检查。
  4. 其他,包含有关发送代理、附件等的消息跟踪信息。这是未来版本将添加更多信息的扩展点之一。

`EMailPresenter` 是一个用于简略查看的嵌入式用户控件,它**打开**完整的阅读器 `EMailReader`。它**包含**

  1. `EMailThreadView` 是一个控件,用于关联用户与其对等方之间对话过程中的 MIME 消息。给定一个相关消息集中的任意消息,它会在树状视图中显示 MIME 消息的线程,该视图表示某条消息回复了哪条消息,以及回复了该消息的消息集。这种功能在公共论坛中很常见,但在作者所知的任何电子邮件客户端中都缺失。Gmail 可以显示这种关联,但以一种混乱作者的方式。即使是两个人的对话,这种树形结构也可能非常复杂,但目前对用户隐藏。
  2. `QueryMessageThreadHanlder` 用于查询宿主程序,以获取由 `ThreadedMessage` 类型对象携带的初始消息和整个关联树信息(参见右侧图),给定当前消息的消息 ID。该处理程序的实现涉及对与当前消息相关的消息集的递归遍历。此处不再进一步讨论。

  3. 消息头信息区域,显示基本 MIME 消息头信息。
  4. `EMailPresenter`,它显示消息的正文。

`EMailPresenter` **包含** `EMailMetaDetails`、`EMailAttachments` 和 `LargeTextBox` 用户控件。

  1. `EMailAttachments` 包含顶部的列表视图和底部的多媒体预览面板。目前可以预览的多媒体数据类型仅限于图像数据。当用户尝试保存附件时,它会发出 `TransCompSelectFolderEvent` 事件,宿主程序必须处理该事件以回答“将数据保存在何处?”的问题。
  2. private void OnSaveAttachment(object sender, RoutedEventArgs e)
    {
        Button btn = e.OriginalSource as Button;
        if (!(btn.DataContext is MimeWrapper))
            return;
        MimeEntity de = (btn.DataContext as MimeWrapper).Entity;
        if (de != null)
        {
            TransCompSelectFolderEventArgs ex = new TransCompSelectFolderEventArgs(
                                     ComponentEvents.TransCompSelectFolderEvent);
            ex.EventTitle = Properties.Resources.AttachmentSaveDirSelWord;
            ex.InitialFolderPath = LastDepositFolder;
            RaiseEvent(ex);
            if (ex.Handled && ex.IsSelectionMade && 
                                       !string.IsNullOrEmpty(ex.SelectedFolderPath))
            {
                if (Directory.Exists(ex.SelectedFolderPath))
                {
                    LastDepositFolder = ex.SelectedFolderPath;
                    SaveFile(ex.SelectedFolderPath, 
                                             de.ContentDisposition_FileName, de);
                }
                else if (ex.SelectedFolderPath.LastIndexOf('\\') != -1)
                {
                    string dir = ex.SelectedFolderPath.Substring(0, 
                                     ex.SelectedFolderPath.LastIndexOf('\\') + 1);
                    if (Directory.Exists(dir))
                        SaveFile(dir,
                                  ex.SelectedFolderPath.Substring(dir.Length), de);
                }
            }
        }
    }
  3. `LargeTextBox` 用于显示 MIME 消息的原始文本数据。由于某些 MIME 消息包含多媒体数据,因此其大小可能非常大。WPF 的 `TextBlock` 控件不适合显示大型文本数据。这里使用了 WPF 的 `FlowDocument` 控件,因为它即使在加载到其中的文本块非常大时也表现得相当好。
  4. `EMailMetaDetails` **包含** `EMailAttachments` 来处理附件、路由信息和消息的其他信息。
    • 回复消息信息。
    • 消息路由信息。
    • 一个用于从消息 MIME 实体中提取更多信息的扩展插槽。

5. 提供 HTTP 请求

5.1 服务器

嵌入式 HTTP 服务器,在一个名为 `HttpContentServer` 的类中实现,由其宿主程序(本库)控制。“Start”方法从 10000 到 60000 生成随机端口,启动 `HttpListener` 类型的“服务器”,并启动一个名为“`ServerThread”的线程,该线程等待传入的请求。

“`ServerThread”方法包含一个无限循环,将“服务器”生成的 HTTP 上下文对象传递给 **GET** 处理程序。发生错误时,服务器返回 500 状态码。

private static void ServerThread()
{
    IsServing = true;
    serverstarting = false;
    HttpListenerContext ctx = null;
    while (!stopserving)
    {
        try
        {
            ctx = server.GetContext();
            if (ctx.Request.HttpMethod != "GET")
            {
                Return500(ctx);
                continue;
            }
            HandlerRequest(ctx);
        }
        catch (Exception ex)
        {
            System.Diagnostics.Trace.WriteLine(ex.Message);
            Return500(ctx);
        }
    }
    IsServing = false;
}

private static void Return500(HttpListenerContext ctx)
{
    HttpListenerResponse resp = ctx.Response;
    resp.StatusCode = 500;
}

5.2 协议实现

HTTP 协议要求一个完整的服务器来处理 **GET**、**POST**、**HEAD**、**PUT**、**DELETE**、**TRACE** 和 **CONNECT** [方法](http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html)。在我们的应用程序场景中,客户端只允许使用 **GET**。其他类型的请求将被忽略或视为错误。

有一套丰富的 [状态](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) 来反映服务器响应请求的不同状态。这里显示的简单服务器只有三种状态

  1. 成功(状态码 **200**),
  2. 未找到(状态码 **404**),和
  3. 错误(状态码 **500**)。

未来当然可以扩展以处理更多状态。

服务器有两种模式:“消息”和“数据”,在提供数据之前,应由宿主程序设置。

  • 消息模式:在提供顶级 MIME 消息部分(MIME 节点)时,服务器设置为消息模式。
  • 数据模式:在提供不包含在消息 MIME 实体中的多媒体数据时,服务器应设置为数据模式,并且 `MediaBuffer` 应由宿主程序初始化和加载。
private static void HandlerRequest(HttpListenerContext ctx)
{
    HttpListenerRequest req = ctx.Request;
    HttpListenerResponse resp = ctx.Response;
    if (State == HttpContentServerState.Data)
    {
        string ct = ContentTypeString;
        if (ct.IndexOf(';') != -1)
            ct = ct.Substring(0, ct.IndexOf(';'));
        resp.StatusCode = 200;
        resp.Headers.Add("Cache-Control: no-cache");
        resp.ContentType = ct.ToLower();
        resp.ContentLength64 = MediaBuffer.Length;
        SendData(resp, MediaBuffer);
    }
    else
        ServeMessage(ctx);
}

在指定了正确的 [HTTP Content-Type](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.17) 后,数据将被发送回浏览器进行显示。对于简单的服务器,用户可以让 `HttpContentServer` 添加其他 HTTP 响应头。

5.3 服务器数据源

通常,HTTP 服务器在准备响应(对请求)时的初始数据很可能是服务器本地文件系统上的一个预先存在的文件。当前的嵌入式 HTTP 服务器从一组通用缓冲区/对象中获取数据,这些缓冲区/对象会针对每个不同的请求进行更新。宿主程序负责将正确的数据加载到其中,具体取决于服务器的模式

  • 消息模式:字段“`CurrentEntity”MIME 实体(树)应更新以对应当前消息。
  • 数据模式:字段“`MediaBuffer”缓冲区应更新以对应当前媒体项。

5.4 禁用浏览器缓存

HTTP 服务器可以通过使用 [Cache-Control 头](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9) 来决定客户端如何保存返回数据。在我们的当前应用程序场景中,不允许客户端(浏览器)缓存任何数据,因此将添加以下响应头

resp.Headers.Add("Cache-Control: no-cache");

用于所有响应。

这是因为对于所有初始请求,我们的初始请求 URI 始终是“/”(后续每个请求都可以有一个可识别的 URI,请查阅代码以了解详细信息),在这种情况下,浏览器将永远不知道何时使用缓存的内容以及何时不使用。

5.5 MIME 相关

MIME 格式规范是一个过于宽泛的主题,在此不作详细讨论。但是,以下几点与当前主题相关

  1. 消息解码问题。有些电子邮件客户端在指定字符编码时会出错,例如拼错编码名称。用户可以通过在“LumiSoft.Net.Mime.dll.config”配置文件中的“userSettings/LumiSoft.Net.Properties.Settings”部分下指定转换规则来修复这些问题,其中设置的名称是“EncodingMapps”。

    转换规则的格式是

    • [rule]  :=  [wrong names] '->' {correct name}(';' [wrong names] '->' {correct name})*
    • [wrong names]  :=  {wrong name}(',' {wrong name})*

    这里 [] 中的项目是终结符节点,{} 中的项目是字符串值的占位符。{wrong name} 是错误编码名称的占位符,{correct name} 是正确编码名称的占位符,'' 中的项目是具体字符串值。使用类似正则表达式的语法进行模式重复,即 ()* 表示括号中的模式可以重复零次或多次。

    为了使其不那么正式,包含的示例配置文件包含了一个规则的示例,该规则目前在我们相关的产品中使用。可以通过在现有规则后添加分号“;”后跟类似的规则来添加更多规则。错误名称是用逗号分隔的错误名称列表。

  2. 显示非 ASCII 字符的问题。有些自制的电子邮件客户端不遵循 MIME 规范,直接将非 ASCII 文本嵌入消息正文和/或消息头中。由于用户很可能使用通过所谓的 Post Office Protocol (POP) 与服务器通信的客户端下载消息,因此只有 ASCII 字符才能可靠传输,下载的消息很可能无法恢复,并且会永久损坏。它们之所以能在基于 Web 的界面中正确显示,是因为现代 Web 服务器不必通过 POP3 协议检索消息。

对于用户来说,“如何遍历 MIME 实体来显示消息?”这样的问题,可以在 HTTP 服务器类中找到部分答案。

6. 使用代码

包含一个简单的演示 WPF 程序,用户可以使用该程序在硬盘上加载预先存在的 MIME 消息进行可视化显示。顶部没有消息头信息,因为这些信息应由我们应用程序场景中的消息列表宿主提供。要进行更完整的显示,用户可以单击右上角的展开按钮打开阅读器。

图:从 CodeProject 读取订阅的电子邮件消息

要将当前库用于用户自己的代码,必须先编译该库,然后将输出的 DLL 放在用户认为合适的插件文件夹中。库的宿主需要一种方法来查找或定义插件目录并加载它,就像在以下示例中所做的那样

public partial class AppWindow : Window
{
    [Import(typeof(IEMailReader))]
    private IEMailReader reader
    {
        get;
        set;
    }

    public AppWindow()
    {
        InitializeComponent();
        // capture the folder selection event
        AddHandler(ComponentEvents.TransCompSelectFolderEvent, 
                  new TransCompSelectFolderEventHandler(OnDepositionSelectFolder));
        string pluginDir = ...;
        if (System.IO.Directory.Exists(pluginDir))
            LoadReader(pluginDir);
    }
    
    ....

    private void LoadReader(string pluginDir)
    {
        DirectoryCatalog categ = new DirectoryCatalog(pluginDir);
        string cname = 
              AttributedModelServices.GetContractName(typeof(IEMailReader));
        System.Linq.Expressions.Expression<Func<ExportDefinition, bool>>
                                exp = a => a.ContractName == cname;
        ImportDefinition id = new ImportDefinition(exp, 
                                cname, ImportCardinality.ExactlyOne, true, true);
        List<Tuple<ComposablePartDefinition, ExportDefinition>> 
                                _plugins = categ.GetExports(id).ToList();
        if (_plugins.Count == 1)
        {
            var cc = new CompositionContainer(categ);
            cc.ComposeParts(this);
            reader.QueryThread = OnQueryThread;
            //...
            //asign it to the visual place holder
            //...
            //signal the success of loading
        }
        else if (_plugins.Count == 0)
        {
            creader.Visibility = System.Windows.Visibility.Collapsed;
            System.Windows.MessageBox.Show("Failed to find any plug-in!");
        }
    }
    ...
    ...
    // the user's own logic to generate message thread tree
    private bool OnQueryThread(string MsgID, out ThreadedMessage StartMsg)
    {
        ...
        StartMsg = ...;
        return true;
    }
    ...
    ...
}

在此处附加了 `TransCompSelectFolderEvent` 事件处理程序,并附加了消息线程查询委托 `QueryThread`。

7. 关注点

以这里介绍的方式编写 MIME 阅读器并不难。更难的是如何处理当今来自各种来源的大量电子邮件。

使用小型、单用途嵌入式 HTTP 服务器的想法可以应用于许多其他基于 HTML 的离线动态或非文件内容显示系统。

8. 最后说明 

演示程序仅用于演示如何与插件库进行交互。因此,为了展示要点,它被保持得尽可能简单。要获得一个可用于实际使用的电子邮件阅读器应用程序,用户需要编写自己的宿主程序,并希望使用此插件作为消息显示方式。

欢迎修改该库以满足您的需求,如果您能将修改和/或改进反馈给作者,我们将不胜感激。

对于那些没有方便方式下载电子邮件进行测试的用户,尽管他们绝对不需要,因为这些是由我们开发的,他们仍然可以选择安装[此免费软件](http://www.archymeta.com/Products/DownLoad.aspx?ProductID=123&lnk=%2fProducts%2fDownloadsList%2fPOP3EmailManager.msi)来完成。上述程序实际上使用此插件来显示电子邮件。

© . All rights reserved.