geary/src/engine/rfc822/rfc822-message.vala
Michael James Gratton bfe665d1a0 Add unit tests for Geary.RFC822.Message body content, fix a few issues.
* src/engine/rfc822/rfc822-message.vala (Message): Fix has_plain_body(),
  handle the case where displayed MIME entities (as opposed to attached
  ones) with no Content-Type default to US-ASCII, per the RFC.

* test/engine/rfc822-message-test.vala (MessageTest): Add tests for
  testing and accessing body content as both plain text and HTML. Use
  GResources for accessing test message bodies rather than extremely long
  const strings.
2018-05-10 13:53:24 +10:00

1104 lines
45 KiB
Vala

/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2018 Michael Gratton <mike@vee.net>
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* An RFC-822 style email message.
*
* Unlike {@link Email}, these objects are always a complete
* representation of an email message, and contain no information
* other than what RFC-822 and its successor RFC documents specify.
*/
public class Geary.RFC822.Message : BaseObject {
/**
* This delegate is an optional parameter to the body constructers that allows callers
* to process arbitrary non-text, inline MIME parts.
*
* This is only called for non-text MIME parts in mixed multipart sections. Inline parts
* referred to by rich text in alternative or related documents must be located by the caller
* and appropriately presented.
*/
public delegate string? InlinePartReplacer(string? filename, Mime.ContentType? content_type,
Mime.ContentDisposition? disposition, string? content_id, Geary.Memory.Buffer buffer);
private const string HEADER_SENDER = "Sender";
private const string HEADER_IN_REPLY_TO = "In-Reply-To";
private const string HEADER_REFERENCES = "References";
private const string HEADER_MAILER = "X-Mailer";
private const string HEADER_BCC = "Bcc";
// Internal note: If a field is added here, it *must* be set in stock_from_gmime().
public RFC822.MailboxAddress? sender { get; private set; default = null; }
public RFC822.MailboxAddresses? from { get; private set; default = null; }
public RFC822.MailboxAddresses? to { get; private set; default = null; }
public RFC822.MailboxAddresses? cc { get; private set; default = null; }
public RFC822.MailboxAddresses? bcc { get; private set; default = null; }
public RFC822.MailboxAddresses? reply_to { get; private set; default = null; }
public RFC822.MessageIDList? in_reply_to { get; private set; default = null; }
public RFC822.MessageIDList? references { get; private set; default = null; }
public RFC822.Subject? subject { get; private set; default = null; }
public string? mailer { get; private set; default = null; }
public Geary.RFC822.Date? date { get; private set; default = null; }
private GMime.Message message;
// Since GMime.Message does a bad job of separating the headers and body (GMime.Message.get_body()
// returns the full message, headers and all), we keep a buffer around that points to the body
// part from the source. This is only needed by get_email(). Unfortunately, we can't always
// set these easily, so sometimes get_email() won't work.
private Memory.Buffer? body_buffer = null;
private size_t? body_offset = null;
public Message(Full full) throws RFC822Error {
GMime.Parser parser = new GMime.Parser.with_stream(Utils.create_stream_mem(full.buffer));
message = parser.construct_message();
if (message == null)
throw new RFC822Error.INVALID("Unable to parse RFC 822 message");
// See the declaration of these fields for why we do this.
body_buffer = full.buffer;
body_offset = (size_t) parser.get_headers_end();
stock_from_gmime();
}
public Message.from_gmime_message(GMime.Message message) {
this.message = message;
stock_from_gmime();
}
public Message.from_buffer(Memory.Buffer full_email) throws RFC822Error {
this(new Geary.RFC822.Full(full_email));
}
public Message.from_parts(Header header, Text body) throws RFC822Error {
GMime.StreamCat stream_cat = new GMime.StreamCat();
stream_cat.add_source(new GMime.StreamMem.with_buffer(header.buffer.get_bytes().get_data()));
stream_cat.add_source(new GMime.StreamMem.with_buffer(body.buffer.get_bytes().get_data()));
GMime.Parser parser = new GMime.Parser.with_stream(stream_cat);
message = parser.construct_message();
if (message == null)
throw new RFC822Error.INVALID("Unable to parse RFC 822 message");
body_buffer = body.buffer;
body_offset = 0;
stock_from_gmime();
}
public Message.from_composed_email(Geary.ComposedEmail email, string? message_id) {
this.message = new GMime.Message(true);
// Required headers
assert(email.from.size > 0);
this.sender = email.sender;
this.from = email.from;
this.date = new RFC822.Date.from_date_time(email.date);
// GMimeMessage.set_sender actually sets the From header - and
// although the API docs make it sound otherwise, it also
// supports a list of addresses
message.set_sender(this.from.to_rfc822_string());
message.set_date_as_string(this.date.serialize());
if (message_id != null)
message.set_message_id(message_id);
// Optional headers
if (email.to != null) {
this.to = email.to;
foreach (RFC822.MailboxAddress mailbox in email.to)
this.message.add_recipient(GMime.RecipientType.TO, mailbox.name, mailbox.address);
}
if (email.cc != null) {
this.cc = email.cc;
foreach (RFC822.MailboxAddress mailbox in email.cc)
this.message.add_recipient(GMime.RecipientType.CC, mailbox.name, mailbox.address);
}
if (email.bcc != null) {
this.bcc = email.bcc;
foreach (RFC822.MailboxAddress mailbox in email.bcc)
this.message.add_recipient(GMime.RecipientType.BCC, mailbox.name, mailbox.address);
}
if (email.sender != null) {
this.sender = email.sender;
this.message.set_header(HEADER_SENDER, email.sender.to_rfc822_string());
}
if (email.reply_to != null) {
this.reply_to = email.reply_to;
this.message.set_reply_to(email.reply_to.to_rfc822_string());
}
if (email.in_reply_to != null) {
this.in_reply_to = new Geary.RFC822.MessageIDList.from_rfc822_string(email.in_reply_to);
this.message.set_header(HEADER_IN_REPLY_TO, email.in_reply_to);
}
if (email.references != null) {
this.references = new Geary.RFC822.MessageIDList.from_rfc822_string(email.references);
this.message.set_header(HEADER_REFERENCES, email.references);
}
if (email.subject != null) {
this.subject = new Geary.RFC822.Subject(email.subject);
this.message.set_subject(email.subject);
}
// User-Agent
if (!Geary.String.is_empty(email.mailer)) {
this.mailer = email.mailer;
this.message.set_header(HEADER_MAILER, email.mailer);
}
// Build the message's body mime parts
Gee.List<GMime.Object> body_parts = new Gee.LinkedList<GMime.Object>();
// Share the body charset and encoding between plain and HTML
// parts, so we don't need to work it out twice.
string? body_charset = null;
GMime.ContentEncoding? body_encoding = null;
// Body: text format (optional)
if (email.body_text != null) {
GMime.Part? body_text = body_data_to_part(email.body_text.data,
ref body_charset,
ref body_encoding,
"text/plain",
true);
body_parts.add(body_text);
}
// Body: HTML format (also optional)
if (email.body_html != null) {
const string CID_URL_PREFIX = "cid:";
Gee.List<GMime.Object> related_parts =
new Gee.LinkedList<GMime.Object>();
// The files that need to have Content IDs assigned
Gee.Map<string,File> inline_files = new Gee.HashMap<string,File>();
inline_files.set_all(email.inline_files);
// Create parts for inline images, if any, and updating
// the IMG SRC attributes as we go. An inline file is only
// included if it is actually referenced by the HTML - it
// may have been deleted by the user after being added.
// First, treat parts that already have Content Ids
// assigned
foreach (string cid in email.cid_files.keys) {
if (email.contains_inline_img_src(CID_URL_PREFIX + cid)) {
File file = email.cid_files[cid];
GMime.Object? inline_part = get_file_part(
file, Geary.Mime.DispositionType.INLINE
);
if (inline_part != null) {
inline_part.set_content_id(cid);
related_parts.add(inline_part);
}
// Don't need to assign a CID to this file, so
// don't process it below any further.
inline_files.unset(cid);
}
}
// Then, treat parts that need to have Content Id
// assigned.
if (!inline_files.is_empty) {
const string CID_TEMPLATE = "inline_%02u@geary";
uint cid_index = 0;
foreach (string name in inline_files.keys) {
string cid = "";
do {
cid = CID_TEMPLATE.printf(cid_index++);
} while (email.cid_files.has_key(cid));
if (email.replace_inline_img_src(name,
CID_URL_PREFIX + cid)) {
GMime.Object? inline_part = get_file_part(
inline_files[name], Geary.Mime.DispositionType.INLINE
);
if (inline_part != null) {
inline_part.set_content_id(cid);
related_parts.add(inline_part);
}
}
}
}
GMime.Object? body_html = body_data_to_part(email.body_html.data,
ref body_charset,
ref body_encoding,
"text/html",
false);
// Assemble the HTML and inline images into a related
// part, if needed
if (!related_parts.is_empty) {
related_parts.insert(0, body_html);
GMime.Object? related_part =
coalesce_related(related_parts, "text/html");
if (related_part != null)
body_html = related_part;
}
body_parts.add(body_html);
}
// Build the message's main part.
Gee.List<GMime.Object> main_parts = new Gee.LinkedList<GMime.Object>();
GMime.Object? body_part = coalesce_parts(body_parts, "alternative");
if (body_part != null)
main_parts.add(body_part);
Gee.List<GMime.Object> attachment_parts = new Gee.LinkedList<GMime.Object>();
foreach (File file in email.attached_files) {
GMime.Object? attachment_part = get_file_part(
file, Geary.Mime.DispositionType.ATTACHMENT
);
if (attachment_part != null)
attachment_parts.add(attachment_part);
}
GMime.Object? attachment_part = coalesce_parts(attachment_parts, "mixed");
if (attachment_part != null)
main_parts.add(attachment_part);
GMime.Object? main_part = coalesce_parts(main_parts, "mixed");
this.message.set_mime_part(main_part);
}
// Makes a copy of the given message without the BCC fields. This is used for sending the email
// without sending the BCC headers to all recipients.
public Message.without_bcc(Message email) {
// GMime doesn't make it easy to get a copy of the body of a message. It's easy to
// make a new message and add in all the headers, but calling set_mime_part() with
// the existing one's get_mime_part() result yields a double Content-Type header in
// the *original* message. Clearly the objects aren't meant to be used like that.
// Barring any better way to clone a message, which I couldn't find by looking at
// the docs, we just dump out the old message to a buffer and read it back in to
// create the new object. Kinda sucks, but our hands are tied.
try {
this.from_buffer (email.message_to_memory_buffer(false, false));
} catch (Error e) {
error("Error creating a memory buffer from a message: %s", e.message);
}
// GMime also drops the ball for the *new* message. When it comes out of the GMime
// Parser, its "mime part" somehow isn't realizing it has a Content-Type header
// already, so whenever you manipulate the headers, it adds a duplicate one. This
// odd looking hack ensures that any header manipulation is done while the "mime
// part" is an empty object, and when we re-set the "mime part", there's only the
// one Content-Type header. In other words, this hack prevents the duplicate
// header, somehow.
GMime.Object original_mime_part = message.get_mime_part();
GMime.Message empty = new GMime.Message(true);
message.set_mime_part(empty.get_mime_part());
message.remove_header(HEADER_BCC);
bcc = null;
message.set_mime_part(original_mime_part);
}
private GMime.Object? coalesce_related(Gee.List<GMime.Object> parts,
string type) {
GMime.Object? part = coalesce_parts(parts, "related");
if (parts.size > 1) {
part.set_header("Type", type);
}
return part;
}
private GMime.Object? coalesce_parts(Gee.List<GMime.Object> parts, string subtype) {
if (parts.size == 0) {
return null;
} else if (parts.size == 1) {
return parts.first();
} else {
GMime.Multipart multipart = new GMime.Multipart.with_subtype(subtype);
foreach (GMime.Object part in parts)
multipart.add(part);
return multipart;
}
}
private GMime.Part? get_file_part(File file,
Geary.Mime.DispositionType disposition) {
if (!file.query_exists())
return null;
FileInfo file_info;
try {
file_info = file.query_info(FileAttribute.STANDARD_CONTENT_TYPE, FileQueryInfoFlags.NONE);
} catch (Error err) {
debug("Error querying info from file: %s", err.message);
return null;
}
GMime.Part part = new GMime.Part();
part.set_disposition(disposition.serialize());
part.set_filename(file.get_basename());
part.set_content_type(new GMime.ContentType.from_string(file_info.get_content_type()));
// This encoding is the initial encoding of the stream.
GMime.StreamGIO stream = new GMime.StreamGIO(file);
stream.set_owner(false);
part.set_content_object(new GMime.DataWrapper.with_stream(stream, GMime.ContentEncoding.BINARY));
part.set_content_encoding(Geary.RFC822.Utils.get_best_encoding(stream));
return part;
}
/**
* Construct a Geary.Email from a Message. NOTE: this requires you to have created
* the Message in such a way that its body_buffer and body_offset fields will be filled
* out. See the various constructors for details. (Otherwise, we don't have a way
* to get the body part directly, because of GMime's shortcomings.)
*/
public Geary.Email get_email(Geary.EmailIdentifier id) throws Error {
assert(body_buffer != null);
assert(body_offset != null);
Geary.Email email = new Geary.Email(id);
email.set_message_header(new Geary.RFC822.Header(new Geary.Memory.StringBuffer(
message.get_headers())));
email.set_send_date(date);
email.set_originators(from, sender, reply_to);
email.set_receivers(to, cc, bcc);
email.set_full_references(null, in_reply_to, references);
email.set_message_subject(subject);
email.set_message_body(new Geary.RFC822.Text(new Geary.Memory.OffsetBuffer(
body_buffer, body_offset)));
return email;
}
/**
* Generates a preview from the email's message body.
*
* If there is no body, the empty string will be returned.
*/
public string get_preview() {
TextFormat format = TextFormat.PLAIN;
string? preview = null;
try {
preview = get_plain_body(false, null);
} catch (Error e) {
try {
format = TextFormat.HTML;
preview = get_html_body(null);
} catch (Error error) {
debug("Could not generate message preview: %s\n and: %s",
e.message, error.message);
}
}
return (preview != null)
? Geary.RFC822.Utils.to_preview_text(preview, format)
: "";
}
/**
* Returns the primary originator of an email, which is defined as the first mailbox address
* in From:, Sender:, or Reply-To:, in that order, depending on availability.
*
* Returns null if no originators are present.
*/
public RFC822.MailboxAddress? get_primary_originator() {
if (from != null && from.size > 0)
return from[0];
if (sender != null)
return sender;
if (reply_to != null && reply_to.size > 0)
return reply_to[0];
return null;
}
public Gee.List<RFC822.MailboxAddress>? get_recipients() {
Gee.List<RFC822.MailboxAddress> addrs = new Gee.ArrayList<RFC822.MailboxAddress>();
if (to != null)
addrs.add_all(to.get_all());
if (cc != null)
addrs.add_all(cc.get_all());
if (bcc != null)
addrs.add_all(bcc.get_all());
return (addrs.size > 0) ? addrs : null;
}
/**
* Returns the {@link Message} as a {@link Memory.Buffer} suitable for in-memory use (i.e.
* with native linefeed characters).
*/
public Memory.Buffer get_native_buffer() throws RFC822Error {
return message_to_memory_buffer(false, false);
}
/**
* Returns the {@link Message} as a {@link Memory.Buffer} suitable for transmission or
* storage (i.e. using protocol-specific linefeeds).
*
* The buffer can also be dot-stuffed if required. See
* [[http://tools.ietf.org/html/rfc2821#section-4.5.2]]
*/
public Memory.Buffer get_network_buffer(bool dotstuffed) throws RFC822Error {
return message_to_memory_buffer(true, dotstuffed);
}
/**
* Determines if the message has one or display HTML parts.
*/
public bool has_html_body() {
return has_body_parts(message.get_mime_part(), "html");
}
/**
* Determines if the message has one or plain text display parts.
*/
public bool has_plain_body() {
return has_body_parts(message.get_mime_part(), "plain");
}
/**
* Determines if the message has any body text/subtype MIME parts.
*
* A body part is one that would be displayed to the user,
* i.e. parts returned by {@link get_html_body} or {@link
* get_plain_body}.
*
* The logic for selecting text nodes here must match that in
* construct_body_from_mime_parts.
*/
private bool has_body_parts(GMime.Object node, string text_subtype) {
bool has_part = false;
// RFC 2045 Section 5.2 allows us to assume
// text/plain US-ASCII if no content type is
// otherwise specified.
Mime.ContentType this_content_type = Mime.ContentType.DISPLAY_DEFAULT;
if (node.get_content_type() != null) {
this_content_type = new Mime.ContentType.from_gmime(
node.get_content_type()
);
}
GMime.Multipart? multipart = node as GMime.Multipart;
if (multipart != null) {
int count = multipart.get_count();
for (int i = 0; i < count && !has_part; ++i) {
has_part = has_body_parts(multipart.get_part(i), text_subtype);
}
} else {
GMime.Part? part = node as GMime.Part;
if (part != null) {
Mime.ContentDisposition? disposition = null;
if (part.get_content_disposition() != null)
disposition = new Mime.ContentDisposition.from_gmime(
part.get_content_disposition()
);
if (disposition == null ||
disposition.disposition_type != Mime.DispositionType.ATTACHMENT) {
if (this_content_type.has_media_type("text") &&
this_content_type.has_media_subtype(text_subtype)) {
has_part = true;
}
}
}
}
return has_part;
}
/**
* This method is the main utility method used by the other body-generating constructors.
*
* Only text/* MIME parts of the specified subtype are added to body. If a non-text part is
* within a multipart/mixed container, the {@link InlinePartReplacer} is invoked.
*
* If to_html is true, the text is run through a filter to HTML-ize it. (Obviously, this
* should be false if text/html is being searched for.).
*
* The final constructed body is stored in the body string.
*
* The initial call should pass the root of this message and UNSPECIFIED as its container
* subtype.
*
* @return Whether a text part with the desired text_subtype was found
*/
private bool construct_body_from_mime_parts(GMime.Object node, Mime.MultipartSubtype container_subtype,
string text_subtype, bool to_html, InlinePartReplacer? replacer, ref string? body) throws RFC822Error {
// RFC 2045 Section 5.2 allows us to assume text/plain
// US-ASCII if no content type is otherwise specified.
Mime.ContentType this_content_type = Mime.ContentType.DISPLAY_DEFAULT;
if (node.get_content_type() != null) {
this_content_type = new Mime.ContentType.from_gmime(
node.get_content_type()
);
}
// If this is a multipart, call ourselves recursively on the children
GMime.Multipart? multipart = node as GMime.Multipart;
if (multipart != null) {
Mime.MultipartSubtype this_subtype = Mime.MultipartSubtype.from_content_type(this_content_type,
null);
bool found_text_subtype = false;
StringBuilder builder = new StringBuilder();
int count = multipart.get_count();
for (int i = 0; i < count; ++i) {
GMime.Object child = multipart.get_part(i);
string? child_body = null;
found_text_subtype |= construct_body_from_mime_parts(child, this_subtype, text_subtype,
to_html, replacer, ref child_body);
if (child_body != null)
builder.append(child_body);
}
if (!String.is_empty(builder.str))
body = builder.str;
return found_text_subtype;
}
// Only process inline leaf parts
GMime.Part? part = node as GMime.Part;
if (part == null)
return false;
Mime.ContentDisposition? disposition = null;
if (part.get_content_disposition() != null)
disposition = new Mime.ContentDisposition.from_gmime(part.get_content_disposition());
// Stop processing if the part is an attachment
if (disposition != null && disposition.disposition_type == Mime.DispositionType.ATTACHMENT)
return false;
// Assemble body from text parts that are not attachments
if (this_content_type != null && this_content_type.has_media_type("text")) {
if (this_content_type.has_media_subtype(text_subtype)) {
body = mime_part_to_memory_buffer(part, true, to_html).to_string();
return true;
}
// We were the wrong kind of text part
return false;
}
// Use inline part replacer *only* for inline parts and if in
// a mixed multipart where each element is to be presented to
// the user as structure dictates; For alternative and
// related, the inline part is referred to elsewhere in the
// document and it's the callers responsibility to locate them
if (replacer != null && disposition != null &&
disposition.disposition_type == Mime.DispositionType.INLINE &&
container_subtype == Mime.MultipartSubtype.MIXED) {
body = replacer(RFC822.Utils.get_clean_attachment_filename(part),
this_content_type,
disposition,
part.get_content_id(),
mime_part_to_memory_buffer(part));
}
return body != null;
}
/**
* A front-end to construct_body_from_mime_parts() that converts its output parameters into
* something that front-facing methods want to return.
*/
private string? internal_get_body(string text_subtype, bool to_html, InlinePartReplacer? replacer)
throws RFC822Error {
string? body = null;
if (!construct_body_from_mime_parts(message.get_mime_part(), Mime.MultipartSubtype.UNSPECIFIED,
text_subtype, to_html, replacer, ref body)) {
throw new RFC822Error.NOT_FOUND("Could not find any \"text/%s\" parts", text_subtype);
}
return body;
}
/**
* Returns the HTML portion of the message body, if present.
*
* Recursively walks the MIME structure (depth-first) serializing
* all text/html MIME parts of the specified type into a single
* UTF-8 string. Non-text MIME parts inside of multipart/mixed
* containers are offered to the {@link InlinePartReplacer}, which
* can either return null or return a string that is inserted in
* lieu of the MIME part into the final document. All other MIME
* parts are ignored.
*
* @throws RFC822Error.NOT_FOUND if an HTML body is not present.
*/
public string? get_html_body(InlinePartReplacer? replacer) throws RFC822Error {
return internal_get_body("html", false, replacer);
}
/**
* Returns the plaintext portion of the message body, if present.
*
* Recursively walks the MIME structure (depth-first) serializing
* all text/plain MIME parts of the specified type into a single
* UTF-8 string. Non-text MIME parts inside of multipart/mixed
* containers are offered to the {@link InlinePartReplacer}, which
* can either return null or return a string that is inserted in
* lieu of the MIME part into the final document. All other MIME
* parts are ignored.
*
* The convert_to_html flag indicates if the plaintext body should
* be converted into HTML. Note that the InlinePartReplacer's
* output is not converted; it's up to the caller to know what
* format to return when invoked.
*
* @throws RFC822Error.NOT_FOUND if a plaintext body is not present.
*/
public string? get_plain_body(bool convert_to_html, InlinePartReplacer? replacer) throws RFC822Error {
return internal_get_body("plain", convert_to_html, replacer);
}
/**
* Return the body as a searchable string. The body in this case should
* include everything visible in the message's body in the client, which
* would be only one body part, plus any visible attachments (which can be
* disabled by passing false in include_sub_messages). Note that values
* that come out of this function are persisted.
*/
public string? get_searchable_body(bool include_sub_messages = true) {
string? body = null;
bool html = false;
try {
body = get_html_body(null);
html = true;
} catch (Error e) {
try {
body = get_plain_body(false, null);
} catch (Error e) {
// Ignore.
}
}
if (body != null && html)
body = Geary.HTML.html_to_text(body);
if (include_sub_messages) {
foreach (Message sub_message in get_sub_messages()) {
// We index a rough approximation of what a client would be
// displaying for each sub-message, including the subject,
// recipients, etc. We can avoid attachments here because
// they're recursively picked up in the top-level message,
// indexed separately.
StringBuilder sub_full = new StringBuilder();
if (sub_message.subject != null) {
sub_full.append(sub_message.subject.to_searchable_string());
sub_full.append("\n");
}
if (sub_message.from != null) {
sub_full.append(sub_message.from.to_searchable_string());
sub_full.append("\n");
}
string? recipients = sub_message.get_searchable_recipients();
if (recipients != null) {
sub_full.append(recipients);
sub_full.append("\n");
}
// Our top-level get_sub_messages() recursively parses the
// whole MIME tree, so when we get the body for a sub-message,
// we don't need to invoke it again.
string? sub_body = sub_message.get_searchable_body(false);
if (sub_body != null)
sub_full.append(sub_body);
if (sub_full.len > 0) {
if (body == null)
body = "";
body += "\n" + sub_full.str;
}
}
}
return body;
}
/**
* Return the full list of recipients (to, cc, and bcc) as a searchable
* string. Note that values that come out of this function are persisted.
*/
public string? get_searchable_recipients() {
string searchable = null;
Gee.List<RFC822.MailboxAddress>? recipient_list = get_recipients();
if (recipient_list != null) {
MailboxAddresses recipients = new MailboxAddresses(recipient_list);
searchable = recipients.to_searchable_string();
}
return searchable;
}
public Memory.Buffer get_content_by_mime_id(string mime_id) throws RFC822Error {
GMime.Part? part = find_mime_part_by_mime_id(message.get_mime_part(), mime_id);
if (part == null)
throw new RFC822Error.NOT_FOUND("Could not find a MIME part with Content-ID %s", mime_id);
return mime_part_to_memory_buffer(part);
}
public string? get_content_filename_by_mime_id(string mime_id) throws RFC822Error {
GMime.Part? part = find_mime_part_by_mime_id(message.get_mime_part(), mime_id);
if (part == null)
throw new RFC822Error.NOT_FOUND("Could not find a MIME part with Content-ID %s", mime_id);
return part.get_filename();
}
private GMime.Part? find_mime_part_by_mime_id(GMime.Object root, string mime_id) {
// If this is a multipart container, check each of its children.
if (root is GMime.Multipart) {
GMime.Multipart multipart = root as GMime.Multipart;
int count = multipart.get_count();
for (int i = 0; i < count; ++i) {
GMime.Part? child_part = find_mime_part_by_mime_id(multipart.get_part(i), mime_id);
if (child_part != null) {
return child_part;
}
}
}
// Otherwise, check this part's content id.
GMime.Part? part = root as GMime.Part;
if (part != null && part.get_content_id() == mime_id) {
return part;
}
return null;
}
// UNSPECIFIED disposition means "return all Mime parts"
internal Gee.List<GMime.Part> get_attachments(
Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED) throws RFC822Error {
Gee.List<GMime.Part> attachments = new Gee.ArrayList<GMime.Part>();
get_attachments_recursively(attachments, message.get_mime_part(), disposition);
return attachments;
}
private void stock_from_gmime() {
this.message.get_header_list().foreach((name, value) => {
switch (name.down()) {
case "from":
this.from = append_address(this.from, value);
break;
case "sender":
try {
this.sender = new RFC822.MailboxAddress.from_rfc822_string(value);
} catch (Error err) {
debug("Could parse subject: %s", err.message);
}
break;
case "reply-to":
this.reply_to = append_address(this.reply_to, value);
break;
case "to":
this.to = append_address(this.to, value);
break;
case "cc":
this.cc = append_address(this.cc, value);
break;
case "bcc":
this.bcc = append_address(this.bcc, value);
break;
case "subject":
this.subject = new RFC822.Subject.decode(value);
break;
case "date":
try {
this.date = new Geary.RFC822.Date(value);
} catch (Error err) {
debug("Could not parse date: %s", err.message);
}
break;
case "in-reply-to":
this.in_reply_to = append_message_id(this.in_reply_to, value);
break;
case "references":
this.references = append_message_id(this.references, value);
break;
case "x-mailer":
this.mailer = GMime.utils_header_decode_text(value);
break;
default:
break;
}
});
}
private MailboxAddresses append_address(MailboxAddresses? existing,
string header_value) {
MailboxAddresses addresses = new MailboxAddresses.from_rfc822_string(header_value);
if (existing != null) {
addresses = existing.append(addresses);
}
return addresses;
}
private MessageIDList append_message_id(MessageIDList? existing,
string header_value) {
MessageIDList ids = new MessageIDList.from_rfc822_string(header_value);
if (existing != null) {
ids = existing.append(ids);
}
return ids;
}
private void get_attachments_recursively(Gee.List<GMime.Part> attachments, GMime.Object root,
Mime.DispositionType requested_disposition) throws RFC822Error {
// If this is a multipart container, dive into each of its children.
GMime.Multipart? multipart = root as GMime.Multipart;
if (multipart != null) {
int count = multipart.get_count();
for (int i = 0; i < count; ++i) {
get_attachments_recursively(attachments, multipart.get_part(i), requested_disposition);
}
return;
}
// If this is an attached message, go through it.
GMime.MessagePart? messagepart = root as GMime.MessagePart;
if (messagepart != null) {
GMime.Message message = messagepart.get_message();
bool is_unknown;
Mime.DispositionType disposition = Mime.DispositionType.deserialize(root.get_disposition(),
out is_unknown);
if (disposition == Mime.DispositionType.UNSPECIFIED || is_unknown) {
// This is often the case, and we'll treat these as attached
disposition = Mime.DispositionType.ATTACHMENT;
}
if (requested_disposition == Mime.DispositionType.UNSPECIFIED || disposition == requested_disposition) {
GMime.Stream stream = new GMime.StreamMem();
message.write_to_stream(stream);
GMime.DataWrapper data = new GMime.DataWrapper.with_stream(stream,
GMime.ContentEncoding.BINARY); // Equivalent to no encoding
GMime.Part part = new GMime.Part.with_type("message", "rfc822");
part.set_content_object(data);
part.set_filename((message.get_subject() ?? _("(no subject)")) + ".eml");
attachments.add(part);
}
get_attachments_recursively(attachments, message.get_mime_part(),
requested_disposition);
return;
}
// Otherwise, check if this part should be an attachment
GMime.Part? part = root as GMime.Part;
if (part == null) {
return;
}
// If requested disposition is not UNSPECIFIED, check if this part matches the requested deposition
Mime.DispositionType part_disposition = Mime.DispositionType.deserialize(part.get_disposition(),
null);
if (requested_disposition != Mime.DispositionType.UNSPECIFIED && requested_disposition != part_disposition)
return;
// skip text/plain and text/html parts that are INLINE or UNSPECIFIED, as they will be used
// as part of the body
if (part.get_content_type() != null) {
Mime.ContentType content_type = new Mime.ContentType.from_gmime(part.get_content_type());
if ((part_disposition == Mime.DispositionType.INLINE || part_disposition == Mime.DispositionType.UNSPECIFIED)
&& content_type.has_media_type("text")
&& (content_type.has_media_subtype("html") || content_type.has_media_subtype("plain"))) {
return;
}
}
attachments.add(part);
}
public Gee.List<Geary.RFC822.Message> get_sub_messages() {
Gee.List<Geary.RFC822.Message> messages = new Gee.ArrayList<Geary.RFC822.Message>();
find_sub_messages(messages, message.get_mime_part());
return messages;
}
private void find_sub_messages(Gee.List<Geary.RFC822.Message> messages, GMime.Object root) {
// If this is a multipart container, check each of its children.
GMime.Multipart? multipart = root as GMime.Multipart;
if (multipart != null) {
int count = multipart.get_count();
for (int i = 0; i < count; ++i) {
find_sub_messages(messages, multipart.get_part(i));
}
return;
}
GMime.MessagePart? messagepart = root as GMime.MessagePart;
if (messagepart != null) {
GMime.Message sub_message = messagepart.get_message();
if (sub_message != null) {
messages.add(new Geary.RFC822.Message.from_gmime_message(sub_message));
} else {
warning("Corrupt message, possibly bug 769697");
}
}
}
private Memory.Buffer message_to_memory_buffer(bool encoded, bool dotstuffed) throws RFC822Error {
ByteArray byte_array = new ByteArray();
GMime.StreamMem stream = new GMime.StreamMem.with_byte_array(byte_array);
stream.set_owner(false);
GMime.StreamFilter stream_filter = new GMime.StreamFilter(stream);
stream_filter.add(new GMime.FilterCRLF(encoded, dotstuffed));
if (message.write_to_stream(stream_filter) < 0)
throw new RFC822Error.FAILED("Unable to write RFC822 message to memory buffer");
if (stream_filter.flush() != 0)
throw new RFC822Error.FAILED("Unable to flush RFC822 message to memory buffer");
return new Memory.ByteBuffer.from_byte_array(byte_array);
}
private Memory.Buffer mime_part_to_memory_buffer(GMime.Part part,
bool to_utf8 = false, bool to_html = false) throws RFC822Error {
Mime.ContentType? content_type = null;
if (part.get_content_type() != null)
content_type = new Mime.ContentType.from_gmime(part.get_content_type());
GMime.DataWrapper? wrapper = part.get_content_object();
if (wrapper == null) {
throw new RFC822Error.INVALID("Could not get the content wrapper for content-type %s",
content_type.to_string());
}
ByteArray byte_array = new ByteArray();
GMime.StreamMem stream = new GMime.StreamMem.with_byte_array(byte_array);
stream.set_owner(false);
if (to_utf8) {
// Assume encoded text, convert to unencoded UTF-8
GMime.StreamFilter stream_filter = new GMime.StreamFilter(stream);
string? charset = (content_type != null) ? content_type.params.get_value("charset") : null;
stream_filter.add(Geary.RFC822.Utils.create_utf8_filter_charset(charset));
bool flowed = (content_type != null) ? content_type.params.has_value_ci("format", "flowed") : false;
bool delsp = (content_type != null) ? content_type.params.has_value_ci("DelSp", "yes") : false;
// Unconditionally remove the CR's in any CRLF sequence, since
// they are effectively a wire encoding.
stream_filter.add(new GMime.FilterCRLF(false, false));
if (flowed)
stream_filter.add(new Geary.RFC822.FilterFlowed(to_html, delsp));
if (to_html) {
if (!flowed)
stream_filter.add(new Geary.RFC822.FilterPlain());
stream_filter.add(new GMime.FilterHTML(
GMime.FILTER_HTML_CONVERT_URLS | GMime.FILTER_HTML_CONVERT_ADDRESSES, 0));
stream_filter.add(new Geary.RFC822.FilterBlockquotes());
}
wrapper.write_to_stream(stream_filter);
stream_filter.flush();
} else {
// Keep as binary
wrapper.write_to_stream(stream);
stream.flush();
}
return new Geary.Memory.ByteBuffer.from_byte_array(byte_array);
}
public string to_string() {
return message.to_string();
}
/**
* Returns a MIME part for some body content.
*
* Determining the appropriate body charset and encoding is
* unfortunately a multi-step process that involves reading it
* completely, several times:
*
* 1. Guess the best charset by scanning the complete body.
* 2. Convert the body into the preferred charset, essential
* to avoid e.g. guessing Base64 encoding for ISO-8859-1
* because of the 0x0's present in UTF bytes with high-bit
* chars.
* 3. Determine, given the correctly encoded charset
* what the appropriate encoding is by scanning the
* complete, encoded body.
*
* This applies to both text/plain and text/html parts, but we
* don't need to do it repeatedly for each, since HTML is 7-bit
* clean ASCII. So if we have guessed both already for a plain
* text body, it will still apply for any HTML part.
*/
private GMime.Part body_data_to_part(uint8[] content,
ref string? charset,
ref GMime.ContentEncoding? encoding,
string content_type,
bool is_flowed) {
GMime.Stream content_stream = new GMime.StreamMem.with_buffer(content);
if (charset == null) {
charset = Geary.RFC822.Utils.get_best_charset(content_stream);
}
GMime.StreamFilter filter_stream = new GMime.StreamFilter(content_stream);
filter_stream.add(new GMime.FilterCharset(UTF8_CHARSET, charset));
if (encoding == null) {
encoding = Geary.RFC822.Utils.get_best_encoding(filter_stream);
}
if (is_flowed && encoding == GMime.ContentEncoding.BASE64) {
// Base64-encoded text needs to have CR's added after LF's
// before encoding, otherwise it breaks format=flowed. See
// Bug 753528.
filter_stream.add(new GMime.FilterCRLF(true, false));
}
GMime.ContentType complete_type =
new GMime.ContentType.from_string(content_type);
complete_type.set_parameter("charset", charset);
if (is_flowed) {
complete_type.set_parameter("format", "flowed");
}
GMime.DataWrapper body = new GMime.DataWrapper.with_stream(
filter_stream, GMime.ContentEncoding.DEFAULT
);
GMime.Part body_part = new GMime.Part();
body_part.set_content_type(complete_type);
body_part.set_content_object(body);
body_part.set_content_encoding(encoding);
return body_part;
}
}