#!/usr/bin/perl # # DW::IncomingEmail # # Shared processing logic for incoming email. Handles post-by-email, # support request routing, and email alias forwarding. Spam/virus # filtering is handled upstream by SES. # # Extracted from DW::Task::IncomingEmail so that both the legacy # DW::TaskQueue worker and the new SES-based worker can share the # same processing pipeline. # # Authors: # Mark Smith # # Copyright (c) 2009-2026 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl itself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. # package DW::IncomingEmail; use strict; use v5.10; use Log::Log4perl; my $log = Log::Log4perl->get_logger(__PACKAGE__); use Digest::SHA qw(sha256_hex); use DW::EmailPost; use DW::Task::SendEmail; use DW::TaskQueue; use File::Temp (); use LJ::Support; use LJ::Sysban; use MIME::Parser; # Process a raw email message. Returns 1 on success (or deliberate drop), # 0 on transient failure (caller should retry). # # Arguments: # $raw_email - scalar containing the raw email bytes # sub process { my ( $class, $raw_email ) = @_; unless ( defined $raw_email && length $raw_email ) { $log->warn("Empty email data, dropping"); return 1; } my $tmpdir = File::Temp::tempdir( CLEANUP => 1 ); die "No tempdir made?" unless -d $tmpdir && -w $tmpdir; my $parser = MIME::Parser->new; $parser->output_dir($tmpdir); my $entity = eval { $parser->parse_data($raw_email) }; if ($@) { $log->error("Can't parse MIME: $@"); return 1; } my $head = $entity->head; $head->unfold; # Normalize alternate incoming domains to the primary domain # so all downstream routing (aliases, support) works unchanged. if ( @LJ::INCOMING_EMAIL_DOMAINS && $LJ::DOMAIN ) { for my $hdr ( 'To', 'Cc' ) { my $val = $head->get($hdr) or next; my $changed; for my $alt (@LJ::INCOMING_EMAIL_DOMAINS) { $changed = 1 if $val =~ s/\@\Q$alt\E/\@$LJ::DOMAIN/gi; } $head->replace( $hdr, $val ) if $changed; } } my $subject = $head->get('Subject') // ''; chomp $subject; $subject = LJ::trim($subject); # See if a hook is registered to handle this message if ( LJ::Hooks::are_hooks("incoming_email_handler") ) { my $errmsg = ""; my $retry = 0; my $rv = LJ::Hooks::run_hook( "incoming_email_handler", entity => $entity, errmsg => \$errmsg, retry => \$retry ); if ($rv) { if ($retry) { $log->warn("Hook requested retry: $errmsg"); return 0; # transient failure } if ($errmsg) { $log->error("Hook error: $errmsg"); return 1; } return 1; } } # Post-by-email my $email_post = DW::EmailPost->get_handler($entity); if ($email_post) { my ( $ok, $status_msg ) = $email_post->process; return 1 if $ok; if ( $email_post->dequeue ) { $log->error("EmailPost dequeued: $status_msg"); return 1; } else { $log->warn("EmailPost retry: $status_msg"); return 0; # transient failure } } # Try email alias forwarding before support routing if ( $class->_try_alias_forward($entity) ) { return 1; } # Support request routing return $class->_route_to_support( $head, $entity, $subject ); } # Check if the recipient matches an email alias and forward if so. # Returns 1 if forwarded, 0 if no alias match. sub _try_alias_forward { my ( $class, $entity ) = @_; return 0 unless $LJ::USER_EMAIL; my $dbr = LJ::get_db_reader() or return 0; my $head = $entity->head; # Check all To/Cc addresses against email_aliases foreach my $a ( Mail::Address->parse( $head->get('To') ), Mail::Address->parse( $head->get('Cc') ) ) { my $address = lc $a->address; # Only check addresses on our domain next unless $address =~ /^(.+)\@\Q$LJ::USER_DOMAIN\E$/; my $local_part = $1; # Normalize dashes to underscores (matches Postfix query behavior) $local_part =~ s/-/_/g; my $alias = "$local_part\@$LJ::USER_DOMAIN"; my $rcpt = $dbr->selectrow_array( "SELECT rcpt FROM email_aliases WHERE alias = ?", undef, $alias ); next unless $rcpt; # Forward to each recipient my @rcpts = grep { $_ } map { LJ::trim($_) } split( /,/, $rcpt ); unless (@rcpts) { $log->warn("Alias $alias has empty rcpt, dropping"); return 1; } my $from_addr = ( Mail::Address->parse( $head->get('From') ) )[0]; my $original_from = $from_addr ? $from_addr->address : 'unknown'; $log->info("Forwarding alias from=$original_from alias=$alias rcpt=$rcpt"); # Rewrite the From header so DKIM/SPF/DMARC align with # dreamwidth.org. Use a per-sender hash so mail clients # group messages from the same original sender together. my $hash = substr( sha256_hex( lc $original_from ), 0, 12 ); my $new_from = "noreply-$hash\@$LJ::DOMAIN"; ( my $safe_from = $original_from ) =~ s/["\\\r\n]//g; $head->replace( 'From', "\"$safe_from via Dreamwidth\" <$new_from>" ); $head->replace( 'Reply-To', $original_from ) unless $head->get('Reply-To'); # Forward via the SendEmail task queue with a dreamwidth.org # envelope sender so SES accepts it. DW::TaskQueue->dispatch( DW::Task::SendEmail->new( { env_from => $new_from, rcpts => \@rcpts, data => $entity->as_string, } ) ); return 1; } return 0; } # Route email to the support system sub _route_to_support { my ( $class, $head, $entity, $subject ) = @_; # Extract body text from MIME entity my $tent = DW::EmailPost->get_entity($entity); $tent ||= DW::EmailPost->get_entity( $entity, 'html' ); unless ($tent) { $log->error("Can't find text or html entity"); return 1; } my $body = $tent->bodyhandle->as_string; $body = LJ::trim($body); my $email2cat = LJ::Support::load_email_to_cat_map(); my $to; my $toarg; foreach my $a ( Mail::Address->parse( $head->get('To') ), Mail::Address->parse( $head->get('Cc') ) ) { my $address = $a->address; my $arg; if ( $address =~ /^(.+)\+(.*)\@(.+)$/ ) { ( $address, $arg ) = ( lc "$1\@$3", $2 ); } if ( defined $LJ::ALIAS_TO_SUPPORTCAT{$address} ) { $address = $LJ::ALIAS_TO_SUPPORTCAT{$address}; } if ( defined $email2cat->{$address} ) { $to = $address; $toarg = $arg; } } unless ($to) { $log->info("Not deliverable to support system (no match To:)"); return 1; } my $adf = ( Mail::Address->parse( $head->get('From') ) )[0]; unless ($adf) { $log->error("Bogus From: header"); return 1; } my $name = $adf->name; my $from = $adf->address; $subject ||= "(No Subject)"; # Is this a reply to another post? if ( $toarg =~ /^(\d+)z(.+)$/ ) { my $spid = $1; my $miniauth = $2; my $sp = LJ::Support::load_request($spid); unless ( LJ::Support::mini_auth($sp) eq $miniauth ) { $log->error("Invalid mini_auth for support request $spid, dropping"); return 1; } if ( LJ::sysban_check( 'support_email', $from ) ) { my $msg = "Support request blocked based on email."; LJ::Sysban::block( 0, $msg, { 'email' => $from } ); $log->info("Dequeued: $msg"); return 1; } if ( LJ::Support::is_locked($sp) ) { $log->info("Request is locked, can't append comment."); return 1; } # Strip authcodes from body $body =~ s!https?://.+/support/act\S+![snipped]!g; $body =~ s!\+(\d)+z\w{1,10}\@!\@!g; $body =~ s!&auth=\S+!!g; # Strip reply quoting $body =~ s!(\S+.*?)-{4,10} Original Message -{4,10}.+!$1!s; $body =~ s!(\S+.*?)\bOn [^\n]+ wrote:\n.+!$1!s; my $splid = LJ::Support::append_request( $sp, { 'type' => 'comment', 'body' => $body, } ); unless ($splid) { $log->error("Error appending request?"); return 1; } LJ::Support::add_email_address( $sp, $from ); LJ::Support::touch_request($spid); return 1; } # Deny list check my ( $content_file, $content ); if ( %LJ::DENY_REQUEST_FROM_EMAIL && $LJ::DENY_REQUEST_FROM_EMAIL{$to} ) { $content_file = $LJ::DENY_REQUEST_FROM_EMAIL{$to}; $content = LJ::load_include($content_file); } if ( $content_file && $content ) { my $email = < $from, 'from' => $LJ::BOGUS_EMAIL, 'subject' => "Your Email to $to", 'body' => $email, 'wrap' => 1, } ); return 1; } # File new support request my @errors; # Convert email body to UTF-8 my $content_type = $head->get('Content-type:'); if ( $content_type =~ /\bcharset=[\'\"]?(\S+?)[\'\"]?[\s\;]/i ) { my $charset = $1; if ( defined $charset && $charset !~ /^UTF-?8$/i && Unicode::MapUTF8::utf8_supported_charset($charset) ) { $body = Unicode::MapUTF8::to_utf8( { -string => $body, -charset => $charset } ); } } my $spid = LJ::Support::file_request( \@errors, { 'spcatid' => $email2cat->{$to}->{'spcatid'}, 'subject' => $subject, 'reqtype' => 'email', 'reqname' => $name, 'reqemail' => $from, 'body' => $body, } ); if (@errors) { $log->error("Support errors: @errors"); return 1; } return 1; } 1;