2025/09/20(土)さくらインターネットMLでFromを書き換えてSPFalignmentを成功させる

2025/9/30改訂

このページの簡便法は、間便法:Gmailにも届くメーリングリスト-さくらインターネット をご参照ください。

さくらインターネットのメーリングリストでFromを書き換える

メーリングリスト改善/さくらインターネット/Reply-toヘッダにて、reply-toの問題は解決しました。
続いて、DMARCを通させるためにFrom書換をしてみましょう。

google groups,Microsoft365などは、From書換をしています。さくらインターネットもコントロールパネルで変更できるようにしてほしいものですが、執筆時点では対応していません。

Fromを書き換えないことには、DMARCのアライメントが通りません(SPFアライメント(alignment)は、ヘッダFROMとMLの発信envelope-from(return-path)の一致をみます)。
ここでは、From 投稿者の表示名_via_ML <MLアドレス> と変更する方法を紹介します。
もし、投稿者の表示名が存在しない場合(=メールアドレスのみの場合)は、投稿者のアドレスの@より左の部分だけを表示名として取り込みます。
これで SPFアライメント は通ることとなります(ヘッダFromとenvelope-from(return-path)が一致)。
DKIMはMLが件名等を書き換えるため、壊れたままとなるので、DKIMアライメントに行き着く前に失敗します。しかし、Gmail は、SPFアライメントか,DKIMアライメントが通れば受信してくれるので、大丈夫です(執筆時点)。

※さくらインターネットは、MLにおいてDKIMを再署名する機能は提供してない。

設定の仕方は、fmlにスクリプトを組み込む方法 をご参照ください。


SUFFIXにつける追加文字(SUFFIX)は自由に変更できます。日本語もOK。
スクリプトを作成するファイル(CF)の文字コードは必ず確認してください。
my $DISPLAY_NAME_SUFFIX = '_via_ML';
my $CF_FILE_ENCODING = 'shiftjis'; # Change this if CF file uses different encoding
出力は、UTF-8 でのMIMEエンコーディングをデフォルトとしていますが、ISO_2022_JPでエンコードしたい場合には、下記のように変更してください。
my $OUTPUT_MIME_ENCODING = 'ISO-2022-JP';
以下スクリプト本体
$START_HOOK = q{
    # ========================================
    # Configuration Section
    # ========================================
    
    # Suffix to append to display names (configurable)
    # Change this value to customize the suffix
    my $DISPLAY_NAME_SUFFIX = '_via_ML';
    
    # Character encoding of this CF file (for Japanese suffix support)
    # Options: 'utf-8', 'shiftjis', 'euc-jp', 'iso-2022-jp'
    my $CF_FILE_ENCODING = 'shiftjis';  # Change this if CF file uses different encoding
    
    # Output MIME encoding for From header
    # Options: 'UTF-8' (international standard), 'ISO-2022-JP' (Japanese traditional)
    my $OUTPUT_MIME_ENCODING = 'UTF-8';  # Change to 'ISO-2022-JP' for legacy systems
    
    # Convert suffix to UTF-8 if it contains non-ASCII characters
    if ($DISPLAY_NAME_SUFFIX =~ /[\x80-\xFF]/) {
        # Suffix contains non-ASCII, decode from CF file encoding
        if ($CF_FILE_ENCODING ne 'utf-8') {
            my $temp = decode($CF_FILE_ENCODING, $DISPLAY_NAME_SUFFIX);
            if (defined $temp) {
                $DISPLAY_NAME_SUFFIX = $temp;
            }
        }
    }
    
    # ========================================
    # Reply-To Header Processing
    # ========================================
    
    # Check if sender is a mailing list member
    if (&MailListMemberP($From_address)) {
        # Member: Set Reply-To to mailing list if not already set
        unless (&GET_HEADER_FIELD_VALUE('reply-to')) {
            &DEFINE_FIELD_FORCED("reply-to", $MAIL_LIST);
        }
    }
    else {
        # Non-member: Add both original address and ML to Reply-To
        if (&GET_HEADER_FIELD_VALUE('reply-to')) {
            # Append ML to existing Reply-To
            &DEFINE_FIELD_FORCED("reply-to", &GET_HEADER_FIELD_VALUE('reply-to') . "," . $MAIL_LIST);
        } else {
            # Create new Reply-To with sender and ML
            &DEFINE_FIELD_FORCED("reply-to", $From_address . "," . $MAIL_LIST);
        }
    }
    
    # ========================================
    # From Header Rewrite Processing
    # ========================================
    
    # Load required modules
    use Encode qw(decode encode);
    use Encode::MIME::Header;
    
    # Get and save original From header
    my $orig_from = &GET_HEADER_FIELD_VALUE('from');
    &DEFINE_FIELD_FORCED('x-original-from', $orig_from);
    
    # Extract display name and email address from From header
    my ($display_name, $email_address) = extract_from_parts($orig_from);
    
    # Build new From header
    my $new_from;
    if ($display_name) {
        # Add configured suffix to display name
        $display_name .= $DISPLAY_NAME_SUFFIX;
        
        # Check if string contains non-ASCII characters
        if ($display_name =~ /[^\x00-\x7F]/) {
            # Ensure proper UTF-8 internal representation
            utf8::decode($display_name) unless utf8::is_utf8($display_name);
            
            # Encode entire display name as single MIME-Header block
            # This creates a proper RFC 2047 encoded-word
            my $encoded_name;
            if ($OUTPUT_MIME_ENCODING eq 'UTF-8') {
                # Use UTF-8 encoding (modern standard)
                $encoded_name = encode('MIME-Header', $display_name);
            } else {
                # Use ISO-2022-JP encoding (Japanese traditional)
                $encoded_name = encode('MIME-Header-ISO_2022_JP', $display_name);
            }
            $new_from = $encoded_name . ' <' . $MAIL_LIST . '>';
        } else {
            # ASCII only, use with quotes
            $display_name =~ s/"/\\"/g;  # Escape existing quotes
            $new_from = '"' . $display_name . '" <' . $MAIL_LIST . '>';
        }
    } else {
        # If no display name found, use local part of email address
        my $local_part = $email_address || 'unknown';
        $local_part =~ s/@.*$//;
        $new_from = '"' . $local_part . $DISPLAY_NAME_SUFFIX . '" <' . $MAIL_LIST . '>';
    }
    
    # Set new From header
    &DEFINE_FIELD_FORCED('from', $new_from);
    
    # ========================================
    # Function: Parse From Header
    # ========================================
    sub extract_from_parts {
        my ($from_header) = @_;
        my $display_name = '';
        my $email_address = '';
        
        # Return empty if From header is empty
        return ('', '') unless $from_header;
        
        # Store original for processing
        my $decoded_from = $from_header;
        
        # Check if header contains MIME encoding
        if ($from_header =~ /=\?[\w\-]+\?[BQbq]\?/) {
            # MIME encoded header detected - decode it
            my $temp = decode('MIME-Header', $from_header);
            if (defined $temp) {
                $decoded_from = $temp;
                # Ensure UTF-8 flag is set properly
                utf8::decode($decoded_from) unless utf8::is_utf8($decoded_from);
            }
        } else {
            # Not MIME encoded - should be ASCII only per RFC 5322
            # Log warning if non-ASCII bytes detected (RFC violation)
            if ($decoded_from =~ /[\x80-\xFF]/) {
                &LOG("warning: Non-ASCII bytes in non-MIME header (RFC violation): $from_header");
                
                # Special case: ISO-2022-JP with escape sequences
                # Some older systems might pass this through
                if ($decoded_from =~ /\x1b\$B/) {
                    my $temp = decode('iso-2022-jp', $decoded_from);
                    if (defined $temp) {
                        $decoded_from = $temp;
                    }
                }
                # For other non-ASCII cases, continue with original
                # (will likely result in using email local part as fallback)
            }
        }
        
        # Parse From header structure
        # Remove any leading/trailing whitespace
        $decoded_from =~ s/^\s+//;
        $decoded_from =~ s/\s+$//;
        
        # Pattern 1: "Display Name" <email@example.com>
        if ($decoded_from =~ /^"([^"]*(?:\\.[^"]*)*)"\s*<([^>]+)>/) {
            $display_name = $1;
            $email_address = $2;
            # Unescape quotes in display name
            $display_name =~ s/\\"/"/g;
        }
        # Pattern 2: Display Name <email@example.com> (no quotes)
        elsif ($decoded_from =~ /^([^<]+?)\s*<([^>]+)>/) {
            $display_name = $1;
            $email_address = $2;
            # Clean up display name
            $display_name =~ s/^\s+//;
            $display_name =~ s/\s+$//;
        }
        # Pattern 3: <email@example.com> (no display name)
        elsif ($decoded_from =~ /<([^>]+)>/) {
            $email_address = $1;
            $display_name = '';
        }
        # Pattern 4: email@example.com (bare email)
        elsif ($decoded_from =~ /([^\s<>]+@[^\s<>]+)/) {
            $email_address = $1;
            $display_name = '';
        }
        else {
            # Unable to parse
            &LOG("warning: Unable to parse From header: $decoded_from");
            # Try to extract email as last resort
            if ($decoded_from =~ /([^\s<>]+@[^\s<>]+)/) {
                $email_address = $1;
            }
        }
        
        # Clean up extracted values
        $email_address =~ s/^\s+//;
        $email_address =~ s/\s+$//;
        
        if ($display_name) {
            # Remove extra whitespace
            $display_name =~ s/^\s+//;
            $display_name =~ s/\s+$//;
            $display_name =~ s/\s+/ /g;
            
            # IMPORTANT: Remove surrounding quotes that may have been included from MIME decode
            # This prevents double-quoting when re-encoding
            $display_name =~ s/^"//;
            $display_name =~ s/"$//;
        }
        
        return ($display_name, $email_address);
    }
    
    # ========================================
    # End of Hook Processing
    # ========================================
};

2024/08/16(金)メーリングリスト改善/さくらインターネット/Reply-toヘッダ

さくらインターネットMLでFromを書き換えてSPFalignmentを成功させる もご参照ください。

さくらインターネットのメーリングリストReply-toを改修する

さくらインターネットのML(mailing list メーリングリスト)の困るところ

チームで仕事しているとML(メーリングリスト)をよく使います。
メーリングリストの登録メンバーだけでコミュニケーションとるにはとても便利ですが、「MLメンバー以外の外部からMLにポストがあり、それに返信するとき」に困った問題が起きます。

登録されていない外部からのメールにreply-to ヘッダが設定されていない場合(大抵設定されていない)、MLのアドレスがreply-toに設定されます。

すると、多くのメーラーにおいて*1「全員返信」をすると、肝心のFromヘッダーの人(外部の人)が宛先に入らず、そのFromの人に返信したつもりなのに、宛先入っていない!ということが起きます。

ということで、それの回避策です。

なお、さくらインターネットのレンタルサーバー(私が使っているもの)は、MLのシステムとしてfmlを使っています。
fmlは、日本人開発者の深町 賢一さんが作成したメーリングリストソフトウエアであり、世界中で使われています。
https://www.fml.org/software/fml/
fmlバイブル: fml 4.0対応
不遜にも改修などといっていますが、ただの設定変更です。fmlは完成度の高い、素晴らしいソフトウエアであり、さくらインターネットが使いこなせていないだけです。

*1 : 秀丸メールはとても優秀で回避策がある

さくらインターネットのメーリングリストの改修

fmlの公式サイトfmlバイブル: fml 4.0対応にたくさんのヒントがありますが、ここでは以下の設定とします。
メーリングリストメンバーからの投稿については、reply-toの設定が元メールにあればそれを生かし、なければreply-toに、MLアドレスを設定する。 メンバー以外の外部からの投稿については、reply-toの設定があれば、当該reply-toに、MLアドレス加え、reply-to設定がなければ、当該外部の方(Fromヘッダにあるアドレス)とMLアドレスをreply-toに設定する。

$START_HOOK =q#
if (&MailListMemberP($From_address)) {
unless (&GET_HEADER_FIELD_VALUE('reply-to')) {
&DEFINE_FIELD_FORCED("reply-to", $MAIL_LIST);
}
}
else {
if (&GET_HEADER_FIELD_VALUE('reply-to')) {
&DEFINE_FIELD_FORCED("reply-to", &GET_HEADER_FIELD_VALUE('reply-to').",".$MAIL_LIST);
}else{
&DEFINE_FIELD_FORCED("reply-to", $From_address.",".$MAIL_LIST);
}
}
#;

インデントをTABでやっているので、WEBの表示上、インデントがうまく表現できていませんがご容赦ください。スペースでインデントしようとも思ったのですが、何かとトラブルのもとになるので、上記のようにしています。

スクリプトを組み込む方法

ここからは、UNIX(Linux)の知識が必要なので、尻込みする人がいるかもですが、たいした操作はしないので、手順とおりやれば大丈夫です。

まず該当のメーリングリストのcfファイルをダウンロードして加工します。
cfファイルの場所は以下のとおりで、ftpアプリ等でダウンロードしましょう。

/home/<アカウント名>/fml/spool/ml/<メーリングリスト名>

cfファイルに拡張子はついていないので、テキストエディタで無理矢理開きます。

cfファイルの最後のほうに、以下の記述があります。
# YOU CAN EDIT MANUALLY AFTER HERE.
この直後に、前掲のスクリプトをコピペします。そしてアップロードして元の場所に戻しましょう。

その後、SSHクライアント*2で、以下のコマンドを順に実行します。
cd fml/spool/ml/<mailing list>
make config.ph
<mailing list>のところは、メーリングリストの@から左の部分です。

cdは、change directory のコマンドであり、そのディレクトリ(フォルダ)の場所まで移動するコマンドです。
うまく移動できなければ、lsコマンド*3も利用して、cfファイルがある場所まで到着してください。

そのうえで、
make config.ph
とします。これでfmlにさきほどのスクリプトが組み込まれました。
エラーがでていなければOKです。

動作確認をしてみましょう。
makeしてから反映までしばらく時間がかかることもあるようです。
参考にさせていただいたサイト
さくらインターネットでメーリングリストを開設したら最初にするべきこと
さくらメーリングリストにおけるreply-to設定 on fml

*2 : 私はTeraTermを使っています

*3 : そのディレクトリにあるファイル、ディレクトリをリスト出力するコマンド

もうひとつのパターン

メーリングリストメンバーからの投稿については、reply-toの設定が元メールにあればそれを生かし、なければreply-toに、MLアドレスを設定する。
メンバー以外の外部からの投稿については、reply-toの設定があればそのまま生かし、reply-to設定がなければ、当該外部の方(Fromヘッダにあるアドレス)をreply-toに設定する。
$START_HOOK =q#
if (&MailListMemberP($From_address)) {
unless (&GET_HEADER_FIELD_VALUE('reply-to')) {
&DEFINE_FIELD_FORCED("reply-to", $MAIL_LIST);
}
}
else {
unless (&GET_HEADER_FIELD_VALUE('reply-to')) {
&DEFINE_FIELD_FORCED("reply-to", $From_address);
}
}
#;