Perl SMTP:无法发送正文中包含非 ASCII 字符的电子邮件

Perl SMTP: can't send email with non-ascii characters in body

代码,发送电子邮件(工作正常):

#!/usr/bin/perl

use utf8;
use strict;
use warnings;

use Email::Sender::Simple qw(sendmail);
use Email::Sender::Transport::SMTP ();
use Email::Simple ();
use open ':std', ':encoding(UTF-8)';

sub send_email
{
    my $email_from = shift;
    my $email_to = shift;
    my $subject = shift;
    my $message = shift;

    my $smtpserver = 'smtp.gmail.com';
    my $smtpport = 465;
    my $smtpuser   = 'user@gmail.com';
    my $password = 'secret';

    my $transport = Email::Sender::Transport::SMTP->new({
        host => $smtpserver,
        port => $smtpport,
        sasl_username => $email_from,
        sasl_password => $password,
        debug    => 1,
        ssl => 1,
    });

    my $email = Email::Simple->create(
        header => [
            To      => $email_to,
            From    => $email_from,
            Subject => $subject,
        ],
        body => $message,
    );

    $email->header_set( 'Content-Type' => 'text/html' );
    $email->header_set( 'charset' => 'UTF-8' );
    sendmail($email, { transport => $transport });
}

send_email('user@gmail.com', 'user@gmail.com', 'Hello', 'test email');

只要我在正文中添加非 ascii 字符:

send_email('user@gmail.com', 'user@gmail.com', 'Hello', 'test email. Русский текст');

它因调试输出中的最后一条消息而挂起:

Net::SMTP::_SSL=GLOB(0x8d41fa0)>>> charset: UTF-8
Net::SMTP::_SSL=GLOB(0x8d41fa0)>>> 
Net::SMTP::_SSL=GLOB(0x8d41fa0)>>> test email. Русский текст
Net::SMTP::_SSL=GLOB(0x8d41fa0)>>> .

如何修复?

TL;TR:修复很简单,但问题本身很复杂。要解决此问题,请添加:

$email = Encode::encode('utf-8',$email->as_string)

在将邮件交给 sendmail(...) 之前。但是请注意这个答案末尾的警告,关于首先在邮件中发送这样的 8 位数据时可能出现的问题。


要真正理解问题并解决问题,必须深入了解 Perl 中套接字中字符与八位字节的处理:

  • Email::Sender::Transport::SMTP 使用 Net::SMTP,它本身使用底层 IO::Socket::SSLIO::Socket::IP(或 IO::Socket::INET)套接字的 syswrite 方法,取决于是否使用了 SSL。
  • syswrite 需要八位字节,并且需要写入套接字的八位字节数。
  • 但是,您使用 Email::Simple return 构造的邮件不是八位字节,而是设置了 UTF8 标志的字符串。在此字符串中,字符数与八位字节数不同,因为俄语 текст 被视为 5 个字符,而在使用 UTF-8 转换时它是 10 个八位字节。
  • Email::Sender::Transport::SMTP 只是将电子邮件的 UTF8 字符串转发给 Net::SMTP,它在 syswrite 中使用它。长度是使用 length 计算的,它给出的字符数与本例中的八位字节数不同。但是在套接字站点上,它将从字符串中取出八位字节而不是字符,并将给定长度视为八位字节数。
  • 因为它将给定的长度视为八位字节而不是字符,所以它最终会按照程序上层的预期向服务器发送更少的数据。
  • 这样邮件结束标记(带单点的行)不会发送,因此服务器正在等待客户端发送更多数据,而客户端不知道要发送更多数据。

以一封仅包含两个俄语字符“ий”的邮件为例。带有行尾和邮件结束标记,它由 7 个字符组成:

ий\r\n.\r\n

但是,这 7 个字符实际上是 9 个八位字节,因为前 2 个字符每个都是两个八位字节

и       й       \r \n   .   \r  \n
d0 b8   d0 b9   0d  0a  2e  0d  0a  

现在,syswrite($fd,"ий\r\n.\r\n",7) 将只写第 7 个字符的前 7 个八位字节,但 9 个八位字节长的字符串:

и       й       \r \n   . 
d0 b8   d0 b9   0d  0a  2e

这意味着邮件结束标记不完整。这意味着邮件服务器将等待更多数据,而邮件客户端不知道它需要发送更多数据。这实质上会导致应用程序挂起。

现在,这又怪谁呢?

有人可能会争辩说 IO::Socket::SSL::syswrite 应该以一种理智的方式处理 UTF8 数据,而这正是 RT#98732 所要求的。但是,IO::Socket::SSL 中 syswrite 的文档清楚地表明它适用于字节。并且由于在考虑非阻塞套接字时几乎不可能创建基于理智的字符的行为,因此该错误被拒绝了。此外,非 SSL 套接字也会遇到 UTF8 字符串的问题:如果您一开始不使用 SSL,程序将不会挂起,而是会因 Wide character in syswrite ... 而崩溃。

下一层是期望 Net::SMTP 正确处理此类 UTF8 字符串。只是,在documentation of Net::SMTP::data中明确表示:

DATA may be a reference to a list or a list and must be encoded by the caller to octets of whatever encoding is required, e.g. by using the Encode module's encode() function.

现在有人可能会争辩说 Email::Transport 应该正确处理 UTF8 字符串,或者 Email::Simple::as_string 一开始就不应该 return UTF8 字符串。

但还可以更上一层楼:开发人员本身。传统上,邮件仅使用 ASCII,在邮件中发送非 ASCII 字符是一个坏主意,因为它只能可靠地与具有 8BITMIME 扩展名的邮件服务器一起工作。如果涉及不支持此扩展的邮件服务器,则结果不可预测,即邮件可以转换(这可能会破坏签名),可以更改为不可读或可能丢失在某处。因此最好使用更复杂的模块,如 Email::MIME 并设置适当的内容传输编码。