2025/09/20(土)さくらインターネットMLでFromを書き換えてSPFalignmentを成功させる
このページの簡便法は、間便法: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';出力は、UTF-8 でのMIMEエンコーディングをデフォルトとしていますが、ISO_2022_JPでエンコードしたい場合には、下記のように変更してください。
my $CF_FILE_ENCODING = 'shiftjis'; # Change this if CF file uses different encoding
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 # ======================================== };