using System.Globalization;
using System.Text;
using MimeKit;
namespace AIStudio.Tools.Mail;
///
/// Turns a mail into the text AI Studio indexes: a header block, then the text a reader of the mail sees.
///
///
/// The text comes from the HTML part whenever there is one. It is what the reader sees, while
/// the plain text part is only what the sender's program claims the mail says, and the two may
/// say different things. The plain text part is read when there is no HTML part, or when nothing
/// visible remains of it. See MailHtmlConverter for what the HTML loses on the way.
///
/// An encrypted mail keeps its header block alone, together with a line saying that and how it is
/// encrypted. Nothing of the encrypted content gets in, neither its blob nor the names of its
/// attachments, and a model can tell the user why it does not know what the mail says.
///
/// The header block is written in English whatever the language of the app, since it is data for
/// the index and the model, like the field names of a tool result. All of it comes from the
/// sender and goes through the prompt injection filter just as the body does.
///
/// The folder is left out of the header block on purpose. A mail which moves to another folder is
/// not embedded again, so a folder written into its text would soon name the wrong one. Where a
/// mail lies is known from its locations in the index instead.
///
public static class MailTextBuilder
{
///
/// How many addresses of one header are named before the rest is only counted. A mail to a
/// whole department would fill the first chunk with addresses otherwise.
///
private const int MAX_LISTED_ADDRESSES = 10;
///
/// How many attachments are named before the rest is only counted.
///
private const int MAX_LISTED_ATTACHMENTS = 20;
///
/// Builds the text of one mail.
///
/// The parts of the mail.
/// The text of the mail.
public static MailText Build(MailTextSource source)
{
var encryptionKind = source.StructureEncryption;
if (encryptionKind is MailEncryptionKind.NONE && (MailEncryptionDetection.ContainsInlinePgpMessage(source.HtmlBody) || MailEncryptionDetection.ContainsInlinePgpMessage(source.TextBody)))
encryptionKind = MailEncryptionKind.PGP_INLINE;
var isReadable = encryptionKind is MailEncryptionKind.NONE;
var importance = MailImportanceDetection.Detect(source.Headers);
var subject = MailTextNormalization.NormalizeHeaderValue(source.Headers[HeaderId.Subject]);
IReadOnlyList attachmentNames = isReadable ? source.AttachmentNames : [];
var headerBlock = BuildHeaderBlock(source.Headers, subject, importance, attachmentNames, encryptionKind);
var (body, bodySource) = isReadable ? ReadBody(source) : (string.Empty, MailBodySource.NONE);
return new(headerBlock, body, subject, encryptionKind, importance, bodySource);
}
private static (string Body, MailBodySource Source) ReadBody(MailTextSource source)
{
if (!string.IsNullOrWhiteSpace(source.HtmlBody))
{
var markdown = MailHtmlConverter.ToMarkdown(source.HtmlBody);
if (markdown.Length > 0)
return (markdown, MailBodySource.HTML);
}
if (string.IsNullOrWhiteSpace(source.TextBody))
return (string.Empty, MailBodySource.NONE);
var text = MailTextNormalization.NormalizeBody(source.TextBody).Trim();
return text.Length > 0 ? (text, MailBodySource.PLAIN_TEXT) : (string.Empty, MailBodySource.NONE);
}
private static string BuildHeaderBlock(HeaderList headers, string subject, MailImportance importance, IReadOnlyList attachmentNames, MailEncryptionKind encryptionKind)
{
var block = new StringBuilder();
AppendLine(block, "From", FormatAddresses(headers, HeaderId.From));
AppendLine(block, "To", FormatAddresses(headers, HeaderId.To));
AppendLine(block, "Cc", FormatAddresses(headers, HeaderId.Cc));
AppendLine(block, "Subject", subject);
AppendLine(block, "Date", MailHeaders.ReadDate(headers)?.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture) ?? string.Empty);
AppendLine(block, "Importance", importance switch
{
MailImportance.HIGH => "high",
MailImportance.LOW => "low",
// Normal is what nearly every mail is, so it is not worth a line:
_ => string.Empty,
});
AppendLine(block, "Attachments", FormatList(attachmentNames.Select(MailTextNormalization.NormalizeHeaderValue).Where(name => name.Length > 0).ToList(), MAX_LISTED_ATTACHMENTS));
AppendLine(block, "Content", DescribeEncryption(encryptionKind));
return block.ToString().TrimEnd('\n');
}
private static void AppendLine(StringBuilder block, string label, string value)
{
if (value.Length > 0)
block.Append(label).Append(": ").Append(value).Append('\n');
}
///
/// Names the addresses of all headers of one kind, e.g. both To lines of a mail which has two.
///
private static string FormatAddresses(HeaderList headers, HeaderId headerId) =>
FormatList(MailHeaders.ReadMailboxes(headers, headerId).Select(FormatAddress).Where(address => address.Length > 0).ToList(), MAX_LISTED_ADDRESSES);
private static string FormatAddress(MailboxAddress mailbox)
{
var address = MailTextNormalization.NormalizeHeaderValue(mailbox.Address);
var name = MailTextNormalization.NormalizeHeaderValue(mailbox.Name);
if (name.Length is 0 || name.Equals(address, StringComparison.OrdinalIgnoreCase))
return address;
return address.Length is 0 ? name : $"{name} <{address}>";
}
private static string FormatList(IReadOnlyList items, int maxListed)
{
if (items.Count <= maxListed)
return string.Join(", ", items);
return $"{string.Join(", ", items.Take(maxListed))}, and {(items.Count - maxListed).ToString(CultureInfo.InvariantCulture)} more";
}
private static string DescribeEncryption(MailEncryptionKind encryptionKind) => encryptionKind switch
{
MailEncryptionKind.NONE => string.Empty,
MailEncryptionKind.SMIME => "encrypted with S/MIME, AI Studio cannot read it",
MailEncryptionKind.SMIME_OPAQUE_SIGNED => "wrapped into an S/MIME signature, AI Studio cannot read it",
MailEncryptionKind.PGP_MIME => "encrypted with PGP/MIME, AI Studio cannot read it",
MailEncryptionKind.PGP_INLINE => "encrypted with PGP, AI Studio cannot read it",
MailEncryptionKind.MICROSOFT_IRM => "protected by Microsoft Information Rights Management, AI Studio cannot read it",
_ => "encrypted, AI Studio cannot read it",
};
}