Re: Fetching short text snippet for mails

Bron Gondwana <[email protected]>
Newsgroups gmane.ietf.imapext
Message-ID <1417554472.2253074.197997697.17E6A5B4@webmail.messagingengine.com>
On Wed, Dec 3, 2014, at 04:49 AM, Arnt Gulbrandsen wrote:
> Does it matter?
>
> As I see it, your proposal aims to solve the download problem. (Which
> is a prioblem. If it takes several roundtrips from when a mobile
> client has put up a notification until it's ready to ship a snippet,
> that's often >1s, painfully long on a mobile.) You don't solve the
> problem of extracting the right text, but rather presume that the
> client has logic to solve that.
>
> Instead of a dubious solution to two problems, you supply a good
> solution to one problem. Good, say I.

As someone who's done both ends of this - your solution isn't enough.
We now calculate an ANNOTATION on the server precisely because it's a
pain to fetch over IMAP.  I'm going to paste our "find the body part to
get preview from" code, so you can see how much complexity went into the
client to get efficient previews from the IMAP server.  And note, you
have to repeat this every time you lose your local cache.

There's plenty around this, but here's the preview and "has attachments"
finding bit.  Notice how it has to batch fetches by which bodystructure part
it wants to fetch the preview from:

    # Get bodystructure for messages we need bodystructure for {{{

    if (my @NeedBSUids = grep { $NeedMoreUids{$_}->{NeedBS} } keys %NeedMoreUids) {
      @NeedBSUids = sort { $a <=> $b } @NeedBSUids;

      # Hold UIDs that need each appropriate flag stored back
      my (@StoreHasAttUids, @StoreHasNoAttUids);

      my $IMAPBS = $Mailbox->fetch(\@NeedBSUids, "(bodystructure)")
        || die "IMAP fetch failed - $@";

      foreach my $Uid (@NeedBSUids) {

        # Get local references to data
        my $Row = $Uid2RowMap{$Uid};
        my $BS = ($IMAPBS->{$Uid} && $IMAPBS->{$Uid}->{bodystructure}) || next;

        # Store back 'hasatt' or 'hasnoatt' flag
        if ($NeedMoreUids{$Uid}->{NeedAttStore}) {

          # Quick heuristic to work out if has attachment
          my $HasAtt = $Self->_HasAttachment($BS);

          # Add id to appropriate list
          if ($HasAtt) {
            push @StoreHasAttUids, $Uid;
          } else {
            push @StoreHasNoAttUids, $Uid;
          }

          # Save new information back into row data
          $Row->{HasAtt} = $HasAtt;
        }

        # Fix 8-bit headers
        if ($NeedMoreUids{$Uid}->{NeedCharset}) {
          my $CharSet = lc($BS->{'Content-Type'}->{charset});

          # If no charset, or we're told it's us-ascii, (but we got here because there
          #  was 8-bit data in the header!), then assume users default charset
          $CharSet = $Self->{FallbackCharset} || 'iso-8859-1'
            if !$CharSet || $CharSet eq 'ascii' || $CharSet eq 'us-ascii';

          # For each 8-bit header, decode it with the charset we now think it's in
          my $Row = $Uid2RowMap{$Uid};
          for (grep { defined($_) && !is_utf8($_) && /[^\x20-\x7f]/ } @$Row{qw(To From Subject)}) {
            eval { $_ = decode($CharSet, $_); };
            # We decoded to utf-8, but there's still control chars? Strip them.
            s/[\x00-\x1f]//g if is_utf8($_) && /[\x00-\x1f]/;
          }
        }

        # Save copy of BS for body part below
        $Uid2BSMap{$Uid} = $BS;
      }

      # Save hasatt/hasnoatt flags back
      $Mailbox->store(\@StoreHasAttUids, '+flags.silent', "(hasatt)")
        if @StoreHasAttUids;
      $Mailbox->store(\@StoreHasNoAttUids, '+flags.silent', "(hasnoatt)")
        if @StoreHasNoAttUids;
    }

    # }}}

    # Get preview text for messages we need preview text for {{{

    if (my @NeedBodyUids = grep { $NeedMoreUids{$_}->{NeedPeek} } keys %NeedMoreUids) {
      @NeedBodyUids = sort { $a <=> $b } @NeedBodyUids;

      # Need to fetch by partnum. To fetch multiple at once, map partnum -> uids
      my (%PartNumToUidMap, %UidToPartInfoMap);
      foreach my $Uid (@NeedBodyUids) {

        # Use find_message to find the body part for this message
        #  (we stored the actual bodystructure for the mesage into
        #  %NeedBSUids above)
        my $Parts = find_message($Uid2BSMap{$Uid});
        my ($Part, $Type) = ($Parts->{text}, 'text');

        # If no text part, but an HTML part, use that
        if (!$Part && exists $Parts->{html}) {
          ($Part, $Type) = ($Parts->{html}, 'html');
        } elsif ($Part) {
          $Type = 'rtf' if lc $Part->{'MIME-Type'} eq 'text/enriched';
        }

        my $Partnum = $Part->{'IMAP-Partnum'} || '';

        push @{$PartNumToUidMap{$Partnum}}, $Uid;
        $UidToPartInfoMap{$Uid} = [ $Part, $Type ];
      }

      # Now iterate by partnums
      foreach my $Partnum (keys %PartNumToUidMap) {
        my @Uids = @{$PartNumToUidMap{$Partnum}};

        # Now get the data for all uids with body in this partnum
        my $BodyTexts = $Mailbox->fetch(\@Uids, "binary.peek[$Partnum]<0.2000>");

        # Bad transfer encodings cause error return, but there might be partial data
        if (!$BodyTexts || !ref($BodyTexts)) {
          $BodyTexts = delete $Mailbox->{PartialResp};
        }

        if (!$BodyTexts || !ref($BodyTexts)) {
          warn "fetch binary for part=$Partnum for uids=@Uids for $Self->{UserId} in $FolderDet->{FolderName} failed: $@";
        }

        # And process each of them
        foreach my $Uid (@Uids) {
          my ($Part, $Type) = @{$UidToPartInfoMap{$Uid}};
          my $BodyText = $BodyTexts->{$Uid} && $BodyTexts->{$Uid}->{binary};
          $BodyText = 'Content unavailable for preview. Probably invalid encoding'
            if !defined $BodyText;

          # Check if we need to decode
          # If any non-ascii chars, we have to decode in some way...
          if ($BodyText =~ /[^\x20-\x7f\r\n]/) {

            # Get and normalise charset
            my $Charset = lc($Part->{'Content-Type'}->{charset});
            $Charset = $Self->{FallbackCharset} || 'iso-8859-1'
              if !$Charset || $Charset eq 'ascii' || $Charset eq 'us-ascii';
            $Charset =~ s/^(iso)[\-_]?(\d+)[\-_](\d+)[\-_]?\w*/$1-$2-$3/i;

            eval { $BodyText = decode($Charset, $BodyText); };
          }

          my @Lines = CleanBodyText($BodyText, $Type);

          # Show the first 3 lines or so
          if ($PeekOutput == 1) {

            $BodyText = substr(join("\n", @Lines[0 .. (@Lines < 3 ? @Lines-1 : 2)]), 0, 1000) . "\n";

          } elsif ($PeekOutput == 2) {
            # Show only 1 line with <CR>'s replaced with \

            # Only first 8 lines and/or 200 chars
            $BodyText = substr(join($Self->{PeekSep} // " \\ ", @Lines[0 .. (@Lines < 8 ? @Lines-1 : 7)]), 0, 200) . "\n";
          } 

          # Store back into row data
          $Uid2RowMap{$Uid}->{BodyPeek} = $BodyText;
          $Uid2RowMap{$Uid}->{BodyType} = $Type;
        }
      }

    }


...

And here's the annotator that runs inside our server now, which calculates image sizes for attached images, a preview, and a bunch of things calculated from headers.

sub MakePreview {
    my ($message, $Parts) = @_;

    my ($Part, $Type) = ($Parts->{text}, 'text');

    # If no text part, but an HTML part, use that
    if (!$Part && exists $Parts->{html}) {
      ($Part, $Type) = ($Parts->{html}, 'html');
    } elsif ($Part) {
      $Type = 'rtf' if lc $Part->{'MIME-Type'} eq 'text/enriched';
    }

    my $Preview = "";
    if ($Part) {
      my $CharSet = lc($Part->{'Content-Type'}->{charset} // '');

      my $Content = $message->read_part_content($Part, MAX_CONTENT);
      $Content = MEDecodeBody($Content, $CharSet);

      my @Lines = clean_body_text($Content, $Type);

      # Want at least 3 lines or 160 characters of preview
      my ($Pos, $Length) = (0, 0);
      while ($Length < 160 && $Pos < @Lines) {
	$Length += length($Lines[$Pos++]) + 1;
      }
      $Pos = 3 if $Pos < 3;
      splice(@Lines, $Pos) if $Pos < @Lines;

      $Preview = join "\n", @Lines;
    }

    return Encode::encode_utf8($Preview);
}

And the client just does this:

    # Get previews for messages we need previews for {{{

    if (my @NeedPeekUids = grep { $NeedMoreUids{$_}->{NeedPeek} } @NeedMoreUids) {

      warn "getting preview for uids=" . join(" ", @NeedPeekUids) if DEBUG;

      my @WantItems = ("annotation", [ "/vendor/messagingengine.com/preview", "value.shared" ]);
      my $IMAPAnnot = $Mailbox->fetch(\@NeedPeekUids, \@WantItems)
        || die "IMAP fetch failed - $@ - items=" . join(" ", @WantItems) . ", uids=" . join(",", @NeedPeekUids);

      my $PeekSep = $Opts{PeekSep} // $Self->{PeekSep} // " \\ ";

      # Now extra data we need
      foreach my $Uid (@NeedPeekUids) {

        my $Row = $Uid2RowMap{$Uid};
	$Row->{BodyPeek} = '';

        # Message can always disappear, handle sanely
        my $MsgAnnot = $IMAPAnnot->{$Uid} // next;
	$MsgAnnot = $MsgAnnot->{annotation} // next;
	$MsgAnnot = $MsgAnnot->{"/vendor/messagingengine.com/preview"}->{"value.shared"} // next;

        # Data is utf-8 octets, \n separated lines
	my @Lines = split /\n/, decode_utf8($MsgAnnot);

        my $BodyText = '';

	# 3 lines
	if ($PeekOutput == 1) {

          splice(@Lines, 3) if @Lines > 3;
	  $BodyText = substr(join("\n", @Lines), 0, 1000) . "\n";

	# 1 line with <CR>'s replaced with \
	} elsif ($PeekOutput == 2) {

	  # Only first 8 lines and/or 130 chars
	  splice(@Lines, 8) if @Lines > 8;
	  $BodyText = substr(join($PeekSep, @Lines), 0, 130);
	}

	# Store back into row data
	$Row->{BodyPeek} = $BodyText;
      }
    }


Significantly simpler to implement on the client, and fewer round trips as well.  Best of all, you calculate it ONCE and store it on the server, rather than having to either cache every single one you ever see, or potentially have to re-calculate it every time you go back to a mailbox you haven't seen for a while.

Bron.

-- 
  Bron Gondwana
  [email protected]

_______________________________________________
imapext mailing list
[email protected]
https://www.ietf.org/mailman/listinfo/imapext
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.