svn commit: r1936170 - in spamassassin/trunk: . lib/Mail/SpamAssassin lib/Mail/SpamAssassin/Handler lib/Mail/SpamAssassin/Message rules t t/data/nice
[email protected] Wed, 15 Jul 2026 19:02:28 -0000
| Newsgroups | gmane.mail.spam.spamassassin.cvs |
|---|---|
| Message-ID | <178414214850.2329137.8715305789419198535@svn03-he-fi> |
Author: fkento
Date: Wed Jul 15 19:02:28 2026
New Revision: 1936170
Log:
Add ICS handler for text/calendar parts
Handler::ICS parses text/calendar parts and renders each event's
SUMMARY and DESCRIPTION into the body so ordinary body rules can match
it (SpamAssassin otherwise skips text/calendar); the same text is also
exposed to the new "icstext" rule type, kept separate from the body.
Links in the URL, ATTACH and LOCATION properties are added to the URI
detail list (type "ics"). Provides the check_ics_attendee_count,
check_ics_random_start_time and check_ics_event_prop evals, the last of
which matches a regex against any event property (parameters included)
for hunting arbitrary or extension properties.
Added:
spamassassin/trunk/lib/Mail/SpamAssassin/Handler/ICS.pm
spamassassin/trunk/t/data/nice/handler_ics
spamassassin/trunk/t/handler_ics.t
Modified:
spamassassin/trunk/MANIFEST
spamassassin/trunk/UPGRADE
spamassassin/trunk/lib/Mail/SpamAssassin/Message.pm
spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm
spamassassin/trunk/rules/v403.pre
Modified: spamassassin/trunk/MANIFEST
==============================================================================
--- spamassassin/trunk/MANIFEST Wed Jul 15 18:45:04 2026 (r1936169)
+++ spamassassin/trunk/MANIFEST Wed Jul 15 19:02:28 2026 (r1936170)
@@ -63,6 +63,7 @@ lib/Mail/SpamAssassin/HTML.pm
lib/Mail/SpamAssassin/HTML/Color.pm
lib/Mail/SpamAssassin/Handler/Archive.pm
lib/Mail/SpamAssassin/Handler/HTML.pm
+lib/Mail/SpamAssassin/Handler/ICS.pm
lib/Mail/SpamAssassin/Handler/Image.pm
lib/Mail/SpamAssassin/Handler/JavaScript.pm
lib/Mail/SpamAssassin/Handler/SVG.pm
@@ -428,6 +429,7 @@ t/data/nice/handler_archive_octet
t/data/nice/handler_archive_rar
t/data/nice/handler_archive_zip
t/data/nice/handler_html
+t/data/nice/handler_ics
t/data/nice/handler_image
t/data/nice/handler_image_heic
t/data/nice/handler_image_octet
@@ -633,6 +635,7 @@ t/handler.t
t/handler_aliases.t
t/handler_archive.t
t/handler_html.t
+t/handler_ics.t
t/handler_image.t
t/handler_image_heic.t
t/handler_image_size.t
Modified: spamassassin/trunk/UPGRADE
==============================================================================
--- spamassassin/trunk/UPGRADE Wed Jul 15 18:45:04 2026 (r1936169)
+++ spamassassin/trunk/UPGRADE Wed Jul 15 19:02:28 2026 (r1936170)
@@ -90,8 +90,8 @@ Note for Users Upgrading to SpamAssassin
Mail::SpamAssassin::Handler class (itself a subclass of
Mail::SpamAssassin::Plugin); see its documentation for the API.
- Six handlers ship with this release. HTML, JavaScript and SVG are
- loaded by default from v403.pre; Image, PDF and Archive are included
+ Seven handlers ship with this release. HTML, JavaScript, SVG and ICS
+ are loaded by default from v403.pre; Image, PDF and Archive are included
commented-out because they depend on external tools or non-core Perl
modules (see below).
@@ -114,6 +114,16 @@ Note for Users Upgrading to SpamAssassin
check_svg_text_ratio eval, which flags a mostly-text "image" by the
ratio of text words to graphics elements.
+ * ICS (text/calendar) parses iCalendar invites and renders each event's
+ SUMMARY and DESCRIPTION into the body so ordinary body rules can match
+ it (SpamAssassin otherwise skips text/calendar); the same text is also
+ exposed to the new "icstext" rule type, kept separate from the body.
+ Links in the URL, ATTACH and LOCATION properties are added to the URI
+ detail list (type "ics"). Provides the check_ics_attendee_count,
+ check_ics_random_start_time and check_ics_event_prop evals, the last of
+ which matches a regex against any event property (parameters included)
+ for hunting arbitrary or extension properties.
+
* Image (image/*) runs the tesseract OCR engine on image parts and
injects the recognised text into the body so ordinary body rules
can match it. Provides the new "imagetext" rule type and the
Added: spamassassin/trunk/lib/Mail/SpamAssassin/Handler/ICS.pm
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Handler/ICS.pm Wed Jul 15 19:02:28 2026 (r1936170)
@@ -0,0 +1,535 @@
+# <@LICENSE>
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# </@LICENSE>
+
+=head1 NAME
+
+Mail::SpamAssassin::Handler::ICS - A MIME-part handler for C<text/calendar> parts
+
+=head1 SYNOPSIS
+
+ loadhandler Mail::SpamAssassin::Handler::ICS
+
+ icstext RULE_NAME /pattern/modifiers
+
+ body ICS_MANY_ATTENDEES eval:check_ics_attendee_count('50')
+ body ICS_RANDOM_DTSTART eval:check_ics_random_start_time()
+
+=head1 DESCRIPTION
+
+This handler parses each iCalendar part and renders the B<text> of every event's
+C<SUMMARY> and C<DESCRIPTION> into the message body, so ordinary C<body> rules can match
+it. The same text can additionally be matched with the new C<icstext> rule type, which
+sees the invite text on its own -- so a word can be scored differently inside an invite
+than in the surrounding body. Links found in the C<URL>, C<ATTACH> and C<LOCATION>
+properties are added to the URI detail list (type C<ics>). It also counts C<ATTENDEE>
+properties and flags events whose C<DTSTART> has a non-zero seconds component (a
+fingerprint of machine-generated / bulk invites, since human and most-client invites
+round to C<:00>) -- see L</EVAL RULES>.
+
+=head1 RETURNS
+
+This handler returns no sub-parts (always an empty list).
+
+=head1 ICS TEXT RULES
+
+ icstext RULENAME /regex/modifiers
+ score RULENAME 1.0
+ describe RULENAME ICS event text matching /regex/
+
+These rules behave like C<rawbody> rules and support the C<multiple> and C<maxhits=N>
+tflags. By default a rule stops at its first match. C<icstext> matches only the
+invite text; to run one of the eval rules below, use a C<body> rule.
+
+=head1 EVAL RULES
+
+ check_ics_attendee_count(MIN, [MAX])
+
+ body RULENAME eval:check_ics_attendee_count(<min>,[max])
+ min: required, invites contain at least x ATTENDEE properties in total
+ max: optional, if specified, must not contain more than x ATTENDEE properties
+
+ check_ics_random_start_time()
+
+ body RULENAME eval:check_ics_random_start_time()
+
+ Fires if any event's DTSTART has a non-zero seconds component
+ (e.g. DTSTART:20250610T120005Z) -- a fingerprint of a machine-generated
+ start time, since human and most-client invites round to whole minutes.
+
+ check_ics_event_prop(NAME, REGEX)
+
+ body RULENAME eval:check_ics_event_prop('ATTACH', 'ENCODING=BASE64')
+
+ Fires if any calendar event has a property named NAME (case-insensitive)
+ whose content matches REGEX. The leading property NAME is stripped -- REGEX
+ sees "params:value" (or just "value" when the property has no parameters) --
+ so a rule for the DESCRIPTION value need not skip past a "DESCRIPTION:"
+ prefix. REGEX is a regular expression; the surrounding /.../ delimiters are
+ optional, so 'ENCODING=BASE64' and '/ENCODING=BASE64/' are equivalent -- use
+ the delimited form when you need flags (e.g. '/foo/i'). Note that even
+ without delimiters it is still a regex, not a literal substring:
+ metacharacters such as . | ( ) are active. Because the parameters are
+ retained, REGEX can still match them as well as the value -- e.g.
+ ENCODING=BASE64, VALUE=BINARY, FMTTYPE=image/png. Useful for finding
+ invites that carry inline attachments or images.
+
+=head1 URI DETAILS
+
+This handler creates a new "ics" URI type. You can detect URIs found in calendar
+invites using the L<URIDetail|Mail::SpamAssassin::Plugin::URIDetail> plugin. For example:
+
+ uri-detail RULENAME type =~ /^ics$/ raw =~ /^https?:\/\/bit\.ly\//
+
+=cut
+
+package Mail::SpamAssassin::Handler::ICS;
+
+use strict;
+use warnings;
+use re 'taint';
+
+use Mail::SpamAssassin::Handler;
+use Mail::SpamAssassin::Logger qw(dbg would_log);
+use Mail::SpamAssassin::Util qw(compile_regexp untaint_var);
+
+our @ISA = qw(Mail::SpamAssassin::Handler);
+
+sub log_dbg { Mail::SpamAssassin::Logger::dbg ("ics: @_"); }
+
+sub new {
+ my ($class, $mailsaobject) = @_;
+ $class = ref($class) || $class;
+ my $self = $class->SUPER::new($mailsaobject);
+ bless ($self, $class);
+
+ $self->register_eval_rule("check_ics_attendee_count",
+ $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
+ $self->register_eval_rule("check_ics_random_start_time",
+ $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
+ $self->register_eval_rule("check_ics_event_prop",
+ $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
+
+ $self->register_handler('text/calendar', 'handle_ics');
+
+ $self->set_config($mailsaobject->{conf});
+
+ return $self;
+}
+
+sub set_config {
+ my ($self, $conf) = @_;
+ my @cmds;
+
+ push (@cmds, (
+ {
+ # icstext RULENAME /pattern/modifiers
+ setting => 'icstext',
+ is_priv => 1,
+ type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
+ code => sub {
+ my ($self, $key, $value, $line) = @_;
+
+ if ($value !~ /^(\S+)\s+(.+)$/) {
+ return $Mail::SpamAssassin::Conf::INVALID_VALUE;
+ }
+ my ($name, $pattern) = ($1, $2);
+
+ my ($re, $err) = compile_regexp($pattern, 1);
+ if (!$re) {
+ dbg("ics: invalid icstext regexp for $name '$pattern': $err");
+ return $Mail::SpamAssassin::Conf::INVALID_VALUE;
+ }
+
+ $conf->{icstext_rules}->{$name} = $re;
+ $self->{parser}->add_test($name, undef,
+ $Mail::SpamAssassin::Conf::TYPE_EMPTY_TESTS);
+ },
+ },
+ ));
+
+ $conf->{parser}->register_commands(\@cmds);
+}
+
+# handle_ics($node, $pms): parse one iCalendar part. Collect SUMMARY/DESCRIPTION
+# text into $pms->{Handler}{ICS}{text}; add any URIs from URL/ATTACH/LOCATION to the
+# URI detail list (type 'ics'); accumulate the ATTENDEE count and derive the
+# "random start time" signal (DTSTART with non-zero seconds) across all parts.
+# Produces no child parts.
+sub handle_ics {
+ my ($self, $node, $pms) = @_;
+
+ my $data = $node->decode();
+ return [] unless defined $data && length $data;
+
+ my $ics = $pms->{Handler}{ICS} ||= {
+ text => [],
+ event_count => 0,
+ props => {},
+ seen => {},
+ };
+
+ # Parse the part into per-event records, then merge each event exactly once,
+ # keyed by its UID. Senders (e.g. Google Calendar) routinely attach the same
+ # invite twice -- once inline as text/calendar and once as a base64
+ # application/ics file -- and both copies reach the handler. The two copies are
+ # NOT byte-identical (different line folding / CRLF), so a raw-bytes fingerprint
+ # would not catch them; the event UID is the RFC 5545 identity of the invite and
+ # is stable across both serialisations. Distinct events (e.g. recurrence
+ # exceptions) have distinct UIDs and are all counted.
+ my $events = $self->_parse_ics($data);
+
+ my @node_text; # SUMMARY/DESCRIPTION text from this node's (deduped) events
+
+ for my $ev ( @$events ) {
+ # Fall back to a content fingerprint for events with no UID, so duplicate
+ # UID-less events across the two parts still collapse without merging
+ # genuinely different UID-less events together.
+ my $key = defined($ev->{uid}) && $ev->{uid} ne ''
+ ? "uid:$ev->{uid}"
+ : 'fp:'.join("\x00", @{$ev->{text}}, map { $_->[0] } @{$ev->{uris}});
+
+ if ($ics->{seen}{$key}++) {
+ log_dbg("skipping duplicate event ($key): ".($node->{name} || '?'));
+ next;
+ }
+
+ push @{ $ics->{text} }, @{ $ev->{text} } if @{ $ev->{text} };
+ push @node_text, @{ $ev->{text} } if @{ $ev->{text} };
+
+ for my $u ( @{ $ev->{uris} } ) {
+ my ($uri, $tag) = @$u;
+ # $tag records which property the URI came from (url/attach/location) for
+ # the debug log only; the URI detail list is tagged just 'ics'.
+ log_dbg("found URI ($tag): $uri");
+ $pms->add_uri_detail_list($uri, { ics => 1 }, 'ICS');
+ }
+
+ $ics->{event_count}++;
+ push @{ $ics->{props}{$_} }, @{ $ev->{props}{$_} } for keys %{ $ev->{props} };
+ log_dbg("ICS event attendees=".scalar(@{ $ev->{props}{ATTENDEE} || [] })
+ .": ".($node->{name} || '?'));
+ }
+
+ # Render this part's calendar text into the body so ordinary body rules can
+ # match it.
+ #
+ # Join the SUMMARY/DESCRIPTION values with a blank line: get_body_text_array_common
+ # collapses single newlines to spaces (only a blank line survives as a break), so a
+ # single "\n" here would run consecutive properties together into one line.
+ if (@node_text) {
+ $node->set_rendered(join("\n\n", @node_text)."\n", 'text/calendar');
+ }
+
+ return [];
+}
+
+# _parse_ics($data): pure-Perl iCalendar parser. Never dies (wrapped in eval);
+# returns an arrayref of per-event records, one per VEVENT, each:
+# { uid => $uid, text => \@summary_description, uris => [ [uri, tag], ... ],
+# props => { NAME => [ raw_line, ... ] } }.
+# props holds every property's raw (unfolded) content line keyed by upper-cased
+# name, for check_ics_event_prop to search (the "random start time" signal is
+# derived from the raw DTSTART line). Returning per-event lets handle_ics dedupe
+# by UID. Only properties inside a VEVENT are considered.
+sub _parse_ics {
+ my ($self, $data) = @_;
+
+ my @events;
+
+ eval {
+ # Normalise line endings, then unfold RFC 5545 folded lines: a CRLF (or LF)
+ # followed by a single space or TAB is a continuation of the previous line.
+ $data =~ s/\r\n?/\n/g;
+ $data =~ s/\n[ \t]//g;
+
+ my @stack; # component nesting (BEGIN:/END:)
+ my $ev; # the VEVENT record currently being built, or undef
+ for my $line (split /\n/, $data) {
+ next if $line eq '';
+
+ my ($name, $params, $val) = _split_content_line($line);
+ next unless defined $name;
+ $name = uc $name; # property names are case-insensitive
+
+ if ($name eq 'BEGIN') {
+ my $comp = uc $val;
+ push @stack, $comp;
+ # Open a fresh event record when we enter a VEVENT.
+ $ev = { uid => undef, text => [], uris => [], props => {} }
+ if $comp eq 'VEVENT';
+ next;
+ }
+ if ($name eq 'END') {
+ my $comp = pop @stack;
+ if (defined $comp && $comp eq 'VEVENT' && $ev) {
+ push @events, $ev;
+ $ev = undef;
+ }
+ next;
+ }
+
+ # Only collect properties directly inside a VEVENT.
+ next unless $ev && @stack && $stack[-1] eq 'VEVENT';
+
+ # Retain every property's content (unfolded, minus the leading NAME) so
+ # check_ics_event_prop can search arbitrary/extension properties, regardless
+ # of the per-name cases below. We keep any parameters ("params:value") -- so
+ # ENCODING=BASE64 / VALUE=BINARY / FMTTYPE=... stay matchable -- but drop the
+ # redundant property name, which the caller already supplied.
+ push @{ $ev->{props}{$name} }, ($params ne '' ? "$params:$val" : $val);
+
+ if ($name eq 'UID') {
+ $ev->{uid} = $val if $val =~ /\S/;
+ }
+ elsif ($name eq 'SUMMARY' || $name eq 'DESCRIPTION') {
+ my $t = _unescape_text($val);
+ push @{ $ev->{text} }, $t if defined $t && $t ne '';
+ }
+ elsif ($name eq 'URL') {
+ push @{ $ev->{uris} }, [ $val, 'url' ] if $val =~ /\S/;
+ }
+ elsif ($name eq 'ATTACH') {
+ # ATTACH is a URI unless it carries inline binary data.
+ next if $params =~ /(?:^|;)\s*VALUE\s*=\s*BINARY\b/i;
+ next if $params =~ /(?:^|;)\s*ENCODING\s*=\s*BASE64\b/i;
+ push @{ $ev->{uris} }, [ $val, 'attach' ] if $val =~ /\S/;
+ }
+ elsif ($name eq 'LOCATION') {
+ # LOCATION is usually free text; only treat it as a URI when it looks
+ # like one.
+ push @{ $ev->{uris} }, [ $val, 'location' ]
+ if $val =~ m{^\s*(?:https?|ftp|mailto):}i;
+ }
+ # ATTENDEE and everything else are captured by the generic props push above;
+ # check_ics_attendee_count counts the ATTENDEE lines directly.
+ }
+ 1;
+ } or do {
+ my $err = $@;
+ chomp $err;
+ log_dbg("parse error: $err");
+ };
+
+ return \@events;
+}
+
+# _split_content_line($line): split an iCalendar content line into
+# (NAME, PARAMS, VALUE). NAME runs up to the first ';' or the first *unquoted* ':'.
+# Params (between the first ';' and the unquoted ':') are returned verbatim. The
+# value is everything after the first unquoted ':'. Quote-aware so a quoted param
+# value containing a ':' (e.g. ALTREP="http://h:8080/") does not mis-split.
+# Returns an empty list if there is no unquoted ':'.
+sub _split_content_line {
+ my ($line) = @_;
+
+ my $in_quote = 0;
+ my $colon = -1;
+ my $semi = -1;
+ for (my $i = 0; $i < length($line); $i++) {
+ my $c = substr($line, $i, 1);
+ if ($c eq '"') {
+ $in_quote = !$in_quote;
+ }
+ elsif (!$in_quote && $c eq ';' && $semi < 0) {
+ $semi = $i;
+ }
+ elsif (!$in_quote && $c eq ':') {
+ $colon = $i;
+ last;
+ }
+ }
+ return () if $colon < 0;
+
+ my ($name, $params);
+ if ($semi >= 0 && $semi < $colon) {
+ $name = substr($line, 0, $semi);
+ $params = substr($line, $semi + 1, $colon - $semi - 1);
+ } else {
+ $name = substr($line, 0, $colon);
+ $params = '';
+ }
+ my $val = substr($line, $colon + 1);
+
+ return ($name, $params, $val);
+}
+
+# _unescape_text($val): undo the RFC 5545 TEXT escapes for SUMMARY/DESCRIPTION.
+# Intentionally loose -- the result only feeds user regexes.
+sub _unescape_text {
+ my ($val) = @_;
+ return $val unless defined $val;
+ $val =~ s/\\n/\n/gi;
+ $val =~ s/\\([,;\\])/$1/g;
+ return $val;
+}
+
+# Compile icstext rules into _run_icstext_rules, mirroring the SVG/PDF handlers.
+sub finish_parsing_end {
+ my ($self, $opts) = @_;
+ my $conf = $opts->{conf};
+
+ return unless exists $conf->{icstext_rules};
+
+ my $would_log = would_log('dbg');
+
+ my $eval = <<'EOF';
+package Mail::SpamAssassin::Handler::ICS;
+
+sub _run_icstext_rules {
+ my ($self, $opts) = @_;
+ my $pms = $opts->{permsgstatus};
+ my ($test_qr, $hits);
+
+ my $ics_text = $self->_get_ics_text($pms);
+ return unless @$ics_text;
+
+EOF
+
+ my $loopid = 0;
+ foreach my $name (keys %{$conf->{icstext_rules}}) {
+ $loopid++;
+ my $tflags = $conf->{tflags}->{$name} || '';
+
+ my ($dbg_running_rule, $dbg_ran_rule) = ('', '');
+ if ($would_log) {
+ $dbg_running_rule = qq(dbg("running rule $name"););
+ $dbg_ran_rule = qq(dbg(qq(ran rule $name ======> got hit "\$match")););
+ }
+
+ my $ifwhile = 'if';
+ my $last = 'last;';
+ my $modifiers = 'p';
+ my $init_hits = '';
+
+ if ($tflags =~ /\bmultiple\b/) {
+ $ifwhile = 'while';
+ $modifiers .= 'g';
+ if ($tflags =~ /\bmaxhits=(\d+)\b/) {
+ $init_hits = "\$hits = 0;";
+ $last = "last rule_$loopid if ++\$hits >= $1;";
+ } else {
+ $last = '';
+ }
+ }
+
+ $eval .= <<"EOF";
+ $dbg_running_rule
+ \$test_qr = \$pms->{conf}->{icstext_rules}->{$name};
+ $init_hits
+ rule_$loopid: foreach my \$line (\@\$ics_text) {
+ $ifwhile ( \$line =~ /\$test_qr/$modifiers ) {
+ my \$match = defined \${^MATCH} ? \${^MATCH} : '<negative match>';
+ $dbg_ran_rule
+ \$pms->got_hit('$name', 'ICS: ', 'ruletype' => 'rawbody');
+ $last
+ }
+ }
+EOF
+ }
+
+ $eval .= "}\n";
+
+ no warnings 'redefine';
+ eval untaint_var($eval);
+ if ($@) {
+ die("ics: error compiling icstext rules: $@");
+ }
+}
+
+# Real implementation is compiled in by finish_parsing_end; no-op otherwise.
+sub _run_icstext_rules { }
+
+sub parsed_metadata {
+ my ($self, $opts) = @_;
+ my $pms = $opts->{permsgstatus};
+
+ # Ensure the structure exists even when the message has no ICS parts, so the
+ # eval rules can read the counters without autovivifying or dying.
+ $pms->{Handler}{ICS} ||= {
+ text => [],
+ event_count => 0,
+ props => {},
+ seen => {},
+ };
+
+ $self->_run_icstext_rules($opts);
+}
+
+sub _get_ics_text {
+ my ($self, $pms) = @_;
+ return ($pms->{Handler}{ICS} && $pms->{Handler}{ICS}{text}) || [];
+}
+
+# Eval rule: true if the total ATTENDEE count across all invites is in [min, max].
+# Only fires when the message actually contains a calendar event -- otherwise a
+# min of 0 (e.g. check_ics_attendee_count(0,0), "invites with no attendees") would
+# match every message, since a message with no invite also has a zero count.
+sub check_ics_attendee_count {
+ my ($self, $pms, $body, $min, $max) = @_;
+ return 0 unless $pms->{Handler}{ICS}->{event_count};
+ my $count = scalar @{ $pms->{Handler}{ICS}->{props}{ATTENDEE} || [] };
+ return _result_check($min, $max, $count);
+}
+
+# Eval rule: true if any event's DTSTART has a non-zero seconds component -- a
+# fingerprint of a machine-generated (rather than human-picked) start time, since
+# human and most-client invites round to whole minutes (YYYYMMDDTHHMM00). Read the
+# seconds straight off the raw DTSTART line (params like TZID sit before the value
+# and can't contain a YYYYMMDDTHHMMSS block, so the anchor is safe).
+sub check_ics_random_start_time {
+ my ($self, $pms, $body) = @_;
+ return 0 unless $pms->{Handler}{ICS}->{event_count};
+ for my $l ( @{ $pms->{Handler}{ICS}->{props}{DTSTART} || [] } ) {
+ return 1 if $l =~ /\d{8}T\d{4}(\d{2})/ && $1 ne '00';
+ }
+ return 0;
+}
+
+# Eval rule: true if any VEVENT contains a property named NAME whose content
+# (unfolded, name-stripped) matches the regex. Matches "params:value" (or just
+# "value" when there are no params), so params like ENCODING=BASE64 / VALUE=BINARY
+# / FMTTYPE=... stay visible, but the redundant NAME prefix is gone.
+# Intended for hunting arbitrary/extension properties (ATTACH, IMAGE, X-*).
+sub check_ics_event_prop {
+ my ($self, $pms, $body, $name, $re) = @_;
+ return 0 unless defined $name && defined $re;
+ return 0 unless $pms->{Handler}{ICS}->{event_count};
+ my $lines = $pms->{Handler}{ICS}->{props}->{uc $name} or return 0;
+ # $re is a regex whose /.../ delimiters are optional; strip_delimiters == 2
+ # strips the delimiters (and honours any trailing flags, e.g. "/foo/i") when
+ # present, and otherwise accepts a bare pattern -- still a regex, not a literal.
+ # (0 would NOT strip, compiling "/foo/" with the slashes as literal chars; 1
+ # would strip but reject a bare pattern.)
+ my ($qr, $err) = compile_regexp($re, 2);
+ if (!$qr) {
+ dbg("ics: invalid check_ics_event_prop regexp '$re': $err");
+ return 0;
+ }
+ for my $l (@$lines) { return 1 if $l =~ $qr; }
+ return 0;
+}
+
+sub _result_check {
+ my ($min, $max, $value, $nomaxequal) = @_;
+ return 0 unless defined $min && defined $value;
+ return 0 if $value < $min;
+ return 0 if defined $max && $value > $max;
+ return 0 if defined $nomaxequal && $nomaxequal && $value == $max;
+ return 1;
+}
+
+1;
Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Message.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Message.pm Wed Jul 15 18:45:04 2026 (r1936169)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Message.pm Wed Jul 15 19:02:28 2026 (r1936170)
@@ -1438,7 +1438,7 @@ sub get_body_text_array_common {
if (defined $rnd) {
# Skip text attachments that are not considered part of the email body,
# unless scan_text_attachments is enabled (e.g. to catch phishing payloads
- # delivered as attached text/html or text/plain files).
+ # delivered as attached text/html or text/plain files).
my $cdisp = $p->{'headers'}->{'content-disposition'}[0];
if(!$self->{scan_text_attachments} and (defined $cdisp and ($cdisp =~ /^attachment;/) and ($type =~ m{^text/(?:plain|html)$}))) {
dbg("$method_name: Skipping text attachment with content-disposition \"$cdisp\"");
Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm Wed Jul 15 18:45:04 2026 (r1936169)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm Wed Jul 15 19:02:28 2026 (r1936170)
@@ -62,6 +62,7 @@ my %file_type_map = (
'heif' => 'image/heif',
'htm' => 'text/html',
'html' => 'text/html',
+ 'ics' => 'text/calendar',
'jfif' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'jpg' => 'image/jpeg',
@@ -83,6 +84,9 @@ my %file_type_map = (
# Map of MIME type aliases
my %mime_type_map = (
'text/x-amp-html' => 'text/html',
+ # iCalendar: text/calendar is canonical (the ICS handler's type).
+ 'application/ics' => 'text/calendar',
+ 'text/x-vcalendar' => 'text/calendar',
# PDF
'application/x-pdf' => 'application/pdf',
'application/acrobat' => 'application/pdf',
Modified: spamassassin/trunk/rules/v403.pre
==============================================================================
--- spamassassin/trunk/rules/v403.pre Wed Jul 15 18:45:04 2026 (r1936169)
+++ spamassassin/trunk/rules/v403.pre Wed Jul 15 19:02:28 2026 (r1936170)
@@ -46,6 +46,15 @@ if can(Mail::SpamAssassin::Conf::feature
loadhandler Mail::SpamAssassin::Handler::SVG
endif
+# ICS Handler - parse text/calendar (iCalendar) invites: match event SUMMARY/DESCRIPTION
+# text with 'icstext' rules, add URLs found in URL/ATTACH/LOCATION properties to the URI
+# detail list, and expose the check_ics_attendee_count / check_ics_random_start_time
+# eval rules.
+#
+if can(Mail::SpamAssassin::Conf::feature_handlers)
+loadhandler Mail::SpamAssassin::Handler::ICS
+endif
+
# Image Handler - OCR image parts with tesseract so body rules can match text that is
# hidden inside images.
#
Added: spamassassin/trunk/t/data/nice/handler_ics
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ spamassassin/trunk/t/data/nice/handler_ics Wed Jul 15 19:02:28 2026 (r1936170)
@@ -0,0 +1,50 @@
+From: [email protected]
+To: [email protected]
+Subject: ics handler test
+Message-Id: <[email protected]>
+Date: Tue, 10 Jun 2025 12:00:00 +0000
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="BOUND"
+
+--BOUND
+Content-Type: text/plain; charset=us-ascii
+
+ORIGINAL_BODY_MARKER
+
+--BOUND
+Content-Type: text/calendar; method=REQUEST; name="invite.ics"
+Content-Disposition: attachment; filename="invite.ics"
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Example//Invite//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:[email protected]
+SUMMARY:ICSSENTINEL Please confirm your docusign invoice
+DESCRIPTION:Click the secure link to review the documents
+URL:https://phish.example/secure
+LOCATION:https://phish.example/room
+ATTENDEE;CN=A:mailto:[email protected]
+ATTENDEE;CN=B:mailto:[email protected]
+ATTACH;FMTTYPE=image/png;ENCODING=BASE64;VALUE=BINARY:iVBORw0KGgoAAAANSUhEUg==
+DTSTART:20250610T120005Z
+DTEND:20250610T130000Z
+END:VEVENT
+END:VCALENDAR
+
+--BOUND
+Content-Type: application/ics; name="invite.ics"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="invite.ics"
+
+QkVHSU46VkNBTEVOREFSDQpWRVJTSU9OOjIuMA0KUFJPRElEOi0vL0V4YW1wbGUvL0ludml0ZS8v
+RU4NCk1FVEhPRDpSRVFVRVNUDQpCRUdJTjpWRVZFTlQNClVJRDoxMjM0NUBleGFtcGxlLmNvbQ0K
+U1VNTUFSWTpJQ1NTRU5USU5FTCBQbGVhc2UgY29uZmlybSB5b3VyIGRvY3VzaWduIGludm9pY2UN
+CkRFU0NSSVBUSU9OOkNsaWNrIHRoZSBzZWN1cmUgbGluaw0KICB0byByZXZpZXcgdGhlIGRvY3Vt
+ZW50cw0KVVJMOmh0dHBzOi8vcGhpc2guZXhhbXBsZS9zZWN1cmUNCkxPQ0FUSU9OOmh0dHBzOi8v
+cGhpc2guZXhhbXBsZS9yb29tDQpBVFRFTkRFRTtDTj1BOm1haWx0bzphQGV4YW1wbGUuY29tDQpB
+VFRFTkRFRTtDTj1COm1haWx0bzpiQGV4YW1wbGUuY29tDQpEVFNUQVJUOjIwMjUwNjEwVDEyMDAw
+NVoNCkRURU5EOjIwMjUwNjEwVDEzMDAwMFoNCkVORDpWRVZFTlQNCkVORDpWQ0FMRU5EQVINCg==
+
+--BOUND--
Added: spamassassin/trunk/t/handler_ics.t
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ spamassassin/trunk/t/handler_ics.t Wed Jul 15 19:02:28 2026 (r1936170)
@@ -0,0 +1,84 @@
+#!/usr/bin/perl -T
+
+use lib '.'; use lib 't';
+use SATest; sa_t_init("handler_ics");
+
+use Test::More;
+
+# ---------------------------------------------------------------------------
+# End-to-end test of Mail::SpamAssassin::Handler::ICS.
+#
+# handler_ics attaches the same invite twice -- once inline as text/calendar and
+# once as a base64 application/ics file (as Google Calendar does) -- carrying a
+# docusign phishing lure in the event SUMMARY/DESCRIPTION, phishing links in
+# URL/LOCATION, two ATTENDEE properties, and a DTSTART with non-zero seconds. We
+# confirm:
+# * icstext rules match the event text (and only the ICS, not the plain body);
+# * a plain body rule matches the event text too -- the handler renders it into
+# the body via set_rendered (SA otherwise skips text/calendar for bug 4843);
+# * check_ics_attendee_count() sees exactly two attendees -- the duplicate copy
+# is deduped by UID, so the count is 2 and not 4;
+# * check_ics_random_start_time() fires on the odd (non-zero seconds) DTSTART;
+# * URIs from the invite reach the URI detail list under type 'ics';
+# * the original text/plain body is preserved.
+#
+# The handler is pure Perl (no external binary), so this test runs everywhere.
+
+plan tests => 10;
+
+tstpre ("
+ loadhandler Mail::SpamAssassin::Handler::ICS
+");
+
+tstlocalrules ('
+ icstext ICS_SENTINEL /ICSSENTINEL/
+ score ICS_SENTINEL 1.0
+ describe ICS_SENTINEL ICS event text reached the ICS handler
+
+ icstext ICS_SUSP /\b(docusign|secure link)\b/i
+ score ICS_SUSP 1.0
+ describe ICS_SUSP suspicious phishing terms in ICS event text
+
+ body ICS_ATTENDEES eval:check_ics_attendee_count(\'2\',\'2\')
+ score ICS_ATTENDEES 1.0
+ describe ICS_ATTENDEES invite has exactly two attendees (duplicate deduped by UID)
+
+ body ICS_DTSEC eval:check_ics_random_start_time()
+ score ICS_DTSEC 1.0
+ describe ICS_DTSEC DTSTART has a non-zero seconds component
+
+ uri-detail ICS_LINK type =~ /^ics$/ raw =~ /phish\.example/
+ score ICS_LINK 1.0
+ describe ICS_LINK a link inside the invite reached the URI list
+
+ body ICS_ORIG /ORIGINAL_BODY_MARKER/
+ score ICS_ORIG 1.0
+ describe ICS_ORIG original body preserved
+
+ body ICS_RENDERED /confirm your docusign invoice/
+ score ICS_RENDERED 1.0
+ describe ICS_RENDERED event text rendered into the body via set_rendered
+
+ body ICS_INLINE_IMG eval:check_ics_event_prop(\'ATTACH\',\'ENCODING=BASE64\')
+ score ICS_INLINE_IMG 1.0
+ describe ICS_INLINE_IMG invite carries an inline base64 attachment
+
+ body ICS_ATTACH_DELIM eval:check_ics_event_prop(\'ATTACH\',\'/ENCODING=BASE64/\')
+ score ICS_ATTACH_DELIM 1.0
+ describe ICS_ATTACH_DELIM delimited regex form matches (delimiters stripped)
+');
+
+%patterns = (
+ ' 1.0 ICS_SENTINEL ', 'ics_text',
+ ' 1.0 ICS_SUSP ', 'ics_suspicious_text',
+ ' 1.0 ICS_ATTENDEES ', 'ics_attendee_count',
+ ' 1.0 ICS_DTSEC ', 'ics_dtstart_nonzero_seconds',
+ ' 1.0 ICS_LINK ', 'ics_uri_detail',
+ ' 1.0 ICS_ORIG ', 'original_body_preserved',
+ ' 1.0 ICS_RENDERED ', 'ics_rendered_into_body',
+ ' 1.0 ICS_INLINE_IMG ','ics_event_prop_matches_raw_param',
+ ' 1.0 ICS_ATTACH_DELIM ','ics_event_prop_delimited_regex',
+);
+
+ok (sarun ("-L -t < data/nice/handler_ics", \&patterns_run_cb));
+ok_all_patterns();