Remove `Message.without_bcc` ctor since we can now get GMime to exclude BCC headers when serialising, avoiding the overhead of taking a complete copy of the message just to strip BCCs. Rename `get_network_buffer` to `get_rfc822_buffer` to be a bit more explicit in that's the way to get an actual RFC822 formatted message. Replace book args with flags, and take a protocol-specific approach rather than feature-specific, because in the end you're either going to want all the SMTP formatting quirks or none of them. Remove custom dot-stuffing support from Smtp.ClientConnection, since it is redundant.
1210 lines
47 KiB
Vala
1210 lines
47 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, EmailHeaderSet {
|
|
|
|
|
|
/**
|
|
* Callback for including non-text MIME entities in message bodies.
|
|
*
|
|
* This delegate is an optional parameter to the body constructors
|
|
* 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(Part part);
|
|
|
|
|
|
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";
|
|
|
|
/** Options to use when serialising a message in RFC 822 format. */
|
|
[Flags]
|
|
public enum RFC822FormatOptions {
|
|
|
|
/** Format for RFC 822 in general. */
|
|
NONE,
|
|
|
|
/**
|
|
* The message should be serialised for transmission via SMTP.
|
|
*
|
|
* SMTP imposes both operational and data-format requirements
|
|
* on RFC 822 style messages. In particular, BCC headers
|
|
* should not be included since they will expose BCC
|
|
* recipients, and lines must be dot-stuffed so as to avoid
|
|
* terminating the message early if a line starting with a `.`
|
|
* is encountered.
|
|
*
|
|
* See [[http://tools.ietf.org/html/rfc5321#section-4.5.2]]
|
|
*/
|
|
SMTP_FORMAT;
|
|
|
|
}
|
|
|
|
|
|
// Internal note: If a header field is added here, it *must* be
|
|
// set in Message.from_gmime_message(), below.
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MailboxAddresses? from { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MailboxAddress? sender { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MailboxAddresses? to { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MailboxAddresses? cc { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MailboxAddresses? bcc { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MailboxAddresses? reply_to { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MessageID? message_id { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MessageIDList? in_reply_to { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.MessageIDList? references { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public RFC822.Subject? subject { get; protected set; default = null; }
|
|
|
|
/** {@inheritDoc} */
|
|
public Geary.RFC822.Date? date { get; protected set; default = null; }
|
|
|
|
/** Value of the X-Mailer header. */
|
|
public string? mailer { get; protected 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 Error {
|
|
GMime.Parser parser = new GMime.Parser.with_stream(
|
|
Utils.create_stream_mem(full.buffer)
|
|
);
|
|
var message = parser.construct_message(get_parser_options());
|
|
if (message == null) {
|
|
throw new Error.INVALID("Unable to parse RFC 822 message");
|
|
}
|
|
|
|
this.from_gmime_message(message);
|
|
|
|
// See the declaration of these fields for why we do this.
|
|
this.body_buffer = full.buffer;
|
|
this.body_offset = (size_t) parser.get_headers_end();
|
|
}
|
|
|
|
public Message.from_gmime_message(GMime.Message message)
|
|
throws Error {
|
|
this.message = message;
|
|
|
|
this.from = to_addresses(message.get_from());
|
|
this.to = to_addresses(message.get_to());
|
|
this.cc = to_addresses(message.get_cc());
|
|
this.bcc = to_addresses(message.get_bcc());
|
|
this.reply_to = to_addresses(message.get_reply_to());
|
|
|
|
var sender = (
|
|
message.get_sender().get_address(0) as GMime.InternetAddressMailbox
|
|
);
|
|
if (sender != null) {
|
|
this.sender = new MailboxAddress.from_gmime(sender);
|
|
}
|
|
|
|
var subject = message.get_subject();
|
|
if (subject != null) {
|
|
this.subject = new Subject(subject);
|
|
}
|
|
|
|
// Use a pointer here to work around GNOME/vala#986
|
|
GLib.DateTime* date = message.get_date();
|
|
if (date != null) {
|
|
this.date = new Date(date);
|
|
}
|
|
|
|
var message_id = message.get_message_id();
|
|
if (message_id != null) {
|
|
this.message_id = new MessageID(message_id);
|
|
}
|
|
|
|
// Since these headers may be specified multiple times, we
|
|
// need to iterate over all of them to find them.
|
|
var headers = message.get_header_list();
|
|
for (int i = 0; i < headers.get_count(); i++) {
|
|
var header = headers.get_header_at(i);
|
|
switch (header.get_name().down()) {
|
|
case "in-reply-to":
|
|
this.in_reply_to = append_message_id(
|
|
this.in_reply_to, header.get_raw_value()
|
|
);
|
|
break;
|
|
|
|
case "references":
|
|
this.references = append_message_id(
|
|
this.references, header.get_raw_value()
|
|
);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.mailer = message.get_header("X-Mailer");
|
|
}
|
|
|
|
public Message.from_buffer(Memory.Buffer full_email)
|
|
throws Error {
|
|
this(new Geary.RFC822.Full(full_email));
|
|
}
|
|
|
|
public Message.from_parts(Header header, Text body)
|
|
throws Error {
|
|
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);
|
|
var message = parser.construct_message(Geary.RFC822.get_parser_options());
|
|
if (message == null) {
|
|
throw new Error.INVALID("Unable to parse RFC 822 message");
|
|
}
|
|
|
|
this.from_gmime_message(message);
|
|
|
|
// See the declaration of these fields for why we do this.
|
|
this.body_buffer = body.buffer;
|
|
this.body_offset = 0;
|
|
}
|
|
|
|
public async Message.from_composed_email(Geary.ComposedEmail email,
|
|
string? message_id,
|
|
GLib.Cancellable? cancellable)
|
|
throws Error {
|
|
this.message = new GMime.Message(true);
|
|
|
|
//
|
|
// Required headers
|
|
|
|
this.from = email.from;
|
|
foreach (RFC822.MailboxAddress mailbox in email.from) {
|
|
this.message.add_mailbox(FROM, mailbox.name, mailbox.address);
|
|
}
|
|
|
|
this.date = email.date;
|
|
this.message.set_date(this.date.value);
|
|
|
|
// Optional headers
|
|
|
|
if (email.to != null) {
|
|
this.to = email.to;
|
|
foreach (RFC822.MailboxAddress mailbox in email.to)
|
|
this.message.add_mailbox(TO, mailbox.name, mailbox.address);
|
|
}
|
|
|
|
if (email.cc != null) {
|
|
this.cc = email.cc;
|
|
foreach (RFC822.MailboxAddress mailbox in email.cc)
|
|
this.message.add_mailbox(CC, mailbox.name, mailbox.address);
|
|
}
|
|
|
|
if (email.bcc != null) {
|
|
this.bcc = email.bcc;
|
|
foreach (RFC822.MailboxAddress mailbox in email.bcc)
|
|
this.message.add_mailbox(BCC, mailbox.name, mailbox.address);
|
|
}
|
|
|
|
if (email.sender != null) {
|
|
this.sender = email.sender;
|
|
this.message.add_mailbox(SENDER, this.sender.name, this.sender.address);
|
|
}
|
|
|
|
if (email.reply_to != null) {
|
|
this.reply_to = email.reply_to;
|
|
foreach (RFC822.MailboxAddress mailbox in email.reply_to)
|
|
this.message.add_mailbox(REPLY_TO, mailbox.name, mailbox.address);
|
|
}
|
|
|
|
if (message_id != null) {
|
|
this.message_id = new MessageID(message_id);
|
|
this.message.set_message_id(message_id);
|
|
}
|
|
|
|
if (email.in_reply_to != null) {
|
|
this.in_reply_to = email.in_reply_to;
|
|
// We could use `this.message.add_mailbox()` in a similar way like
|
|
// we did for the other headers, but this would require to change
|
|
// the type of `email.in_reply_to` and `this.in_reply_to` from
|
|
// `RFC822.MessageIDList` to `RFC822.MailboxAddresses`.
|
|
this.message.set_header(HEADER_IN_REPLY_TO,
|
|
email.in_reply_to.to_rfc822_string(),
|
|
Geary.RFC822.get_charset());
|
|
}
|
|
|
|
if (email.references != null) {
|
|
this.references = email.references;
|
|
this.message.set_header(HEADER_REFERENCES,
|
|
email.references.to_rfc822_string(),
|
|
Geary.RFC822.get_charset());
|
|
}
|
|
|
|
if (email.subject != null) {
|
|
this.subject = email.subject;
|
|
this.message.set_subject(email.subject.value,
|
|
Geary.RFC822.get_charset());
|
|
}
|
|
|
|
// User-Agent
|
|
if (!Geary.String.is_empty(email.mailer)) {
|
|
this.mailer = email.mailer;
|
|
this.message.set_header(HEADER_MAILER, email.mailer,
|
|
Geary.RFC822.get_charset());
|
|
}
|
|
|
|
// Build the message's body mime parts
|
|
|
|
Gee.List<GMime.Object> body_parts = new Gee.LinkedList<GMime.Object>();
|
|
|
|
// Share the body charset between plain and HTML parts, so we
|
|
// don't need to work it out twice. This doesn't work for the
|
|
// content encoding however since the HTML encoding may need
|
|
// to be different, e.g. if it contains lines longer than
|
|
// allowed by RFC822/SMTP.
|
|
string? body_charset = null;
|
|
|
|
// Body: text format (optional)
|
|
if (email.body_text != null) {
|
|
GMime.Part? body_text = null;
|
|
try {
|
|
body_text = yield body_data_to_part(
|
|
email.body_text.data,
|
|
null,
|
|
"text/plain",
|
|
true,
|
|
cancellable
|
|
);
|
|
} catch (GLib.Error err) {
|
|
warning("Error creating text body part: %s", err.message);
|
|
}
|
|
if (body_text != null) {
|
|
body_charset = body_text.get_content_type().get_parameter(
|
|
"charset"
|
|
);
|
|
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,Memory.Buffer> inline_files = new Gee.HashMap<string,Memory.Buffer>();
|
|
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)) {
|
|
GMime.Object? inline_part = null;
|
|
try {
|
|
inline_part = yield get_buffer_part(
|
|
email.cid_files[cid],
|
|
GLib.Path.get_basename(cid),
|
|
Geary.Mime.DispositionType.INLINE,
|
|
cancellable
|
|
);
|
|
} catch (GLib.Error err) {
|
|
warning(
|
|
"Error creating CID part %s: %s",
|
|
cid,
|
|
err.message
|
|
);
|
|
}
|
|
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 = null;
|
|
try {
|
|
inline_part = yield get_buffer_part(
|
|
inline_files[name],
|
|
GLib.Path.get_basename(name),
|
|
Geary.Mime.DispositionType.INLINE,
|
|
cancellable
|
|
);
|
|
} catch (GLib.Error err) {
|
|
warning(
|
|
"Error creating inline file part %s: %s",
|
|
name,
|
|
err.message
|
|
);
|
|
}
|
|
if (inline_part != null) {
|
|
inline_part.set_content_id(cid);
|
|
related_parts.add(inline_part);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
GMime.Object? body_html = null;
|
|
try {
|
|
body_html = yield body_data_to_part(
|
|
email.body_html.data,
|
|
body_charset,
|
|
"text/html",
|
|
false,
|
|
cancellable
|
|
);
|
|
} catch (GLib.Error err) {
|
|
warning("Error creating html body part: %s", err.message);
|
|
}
|
|
|
|
// 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 = null;
|
|
try {
|
|
attachment_part = yield get_file_part(
|
|
file,
|
|
Geary.Mime.DispositionType.ATTACHMENT,
|
|
cancellable
|
|
);
|
|
} catch (GLib.Error err) {
|
|
warning(
|
|
"Error creating attachment file part %s: %s",
|
|
file.get_path(),
|
|
err.message
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
|
|
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, Geary.RFC822.get_charset());
|
|
}
|
|
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 async GMime.Part? get_file_part(File file,
|
|
Geary.Mime.DispositionType disposition,
|
|
GLib.Cancellable cancellable)
|
|
throws GLib.Error {
|
|
FileInfo file_info = yield file.query_info_async(
|
|
FileAttribute.STANDARD_CONTENT_TYPE,
|
|
FileQueryInfoFlags.NONE
|
|
);
|
|
|
|
GMime.Part part = new GMime.Part.with_type("text", "plain");
|
|
part.set_disposition(disposition.serialize());
|
|
part.set_filename(file.get_basename());
|
|
|
|
GMime.ContentType content_type = GMime.ContentType.parse(
|
|
Geary.RFC822.get_parser_options(),
|
|
file_info.get_content_type()
|
|
);
|
|
part.set_content_type(content_type);
|
|
|
|
GMime.StreamGIO stream = new GMime.StreamGIO(file);
|
|
stream.set_owner(false);
|
|
|
|
return yield finalise_attachment_part(stream, part, content_type, cancellable);
|
|
}
|
|
|
|
/**
|
|
* Create a GMime part for the provided attachment buffer
|
|
*/
|
|
private async GMime.Part? get_buffer_part(Memory.Buffer buffer,
|
|
string basename,
|
|
Geary.Mime.DispositionType disposition,
|
|
GLib.Cancellable cancellable)
|
|
throws GLib.Error {
|
|
Mime.ContentType? mime_type = Mime.ContentType.guess_type(
|
|
basename,
|
|
buffer
|
|
);
|
|
|
|
if (mime_type == null) {
|
|
throw new Error.INVALID(
|
|
_("Could not determine mime type for “%s”.").printf(basename)
|
|
);
|
|
}
|
|
|
|
GMime.ContentType? content_type = GMime.ContentType.parse(
|
|
Geary.RFC822.get_parser_options(),
|
|
mime_type.get_mime_type()
|
|
);
|
|
|
|
if (content_type == null) {
|
|
throw new Error.INVALID(
|
|
_("Could not determine content type for mime type “%s” on “%s”.").printf(mime_type.to_string(), basename)
|
|
);
|
|
}
|
|
|
|
GMime.Part part = new GMime.Part.with_type("text", "plain");
|
|
part.set_disposition(disposition.serialize());
|
|
part.set_filename(basename);
|
|
part.set_content_type(content_type);
|
|
|
|
GMime.StreamMem stream = Utils.create_stream_mem(buffer);
|
|
|
|
return yield finalise_attachment_part(stream, part, content_type, cancellable);
|
|
}
|
|
|
|
/**
|
|
* Set encoding and content object on GMime part
|
|
*/
|
|
private async GMime.Part finalise_attachment_part(GMime.Stream stream,
|
|
GMime.Part part,
|
|
GMime.ContentType content_type,
|
|
GLib.Cancellable cancellable)
|
|
throws GLib.Error {
|
|
|
|
// Text parts should be scanned fully to determine best
|
|
// (i.e. most compact) transport encoding to use, but
|
|
// that's usually fine since they tend to be
|
|
// small. Non-text parts are nearly always going to be
|
|
// binary, so we just assume they require Base64.
|
|
//
|
|
// XXX We should be setting the content encoding lazily
|
|
// though because if sending via a MTA that supports 8-bit
|
|
// or binary transfer modes, we can avoid using a content
|
|
// encoding altogether.
|
|
GMime.ContentEncoding encoding = BASE64;
|
|
if (content_type.is_type("text", Mime.ContentType.WILDCARD)) {
|
|
encoding = yield Utils.get_best_encoding(
|
|
stream,
|
|
GMime.EncodingConstraint.7BIT,
|
|
cancellable
|
|
);
|
|
}
|
|
|
|
part.set_content_encoding(encoding);
|
|
part.set_content(
|
|
new GMime.DataWrapper.with_stream(
|
|
stream, GMime.ContentEncoding.BINARY
|
|
)
|
|
);
|
|
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 GLib.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(Geary.RFC822.get_format_options()))));
|
|
email.set_send_date(date);
|
|
email.set_originators(from, sender, reply_to);
|
|
email.set_receivers(to, cc, bcc);
|
|
email.set_full_references(message_id, 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)));
|
|
string preview = get_preview();
|
|
if (preview != "") {
|
|
email.set_message_preview(new PreviewText.from_string(preview));
|
|
}
|
|
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)
|
|
: "";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Serialises the message using native (i.e. LF) line endings.
|
|
*/
|
|
public Memory.Buffer get_native_buffer() throws Error {
|
|
return message_to_memory_buffer(false, NONE);
|
|
}
|
|
|
|
/**
|
|
* Serialises the message using RFC 822 (i.e. CRLF) line endings.
|
|
*
|
|
* Returns the message as a memory buffer suitable for network
|
|
* transmission and interoperability with other RFC 822 consumers.
|
|
*/
|
|
public Memory.Buffer get_rfc822_buffer(RFC822FormatOptions options = NONE)
|
|
throws Error {
|
|
return message_to_memory_buffer(true, options);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
Part part = new Part(node);
|
|
bool is_matching_part = false;
|
|
|
|
if (node is GMime.Multipart) {
|
|
GMime.Multipart multipart = (GMime.Multipart) node;
|
|
int count = multipart.get_count();
|
|
for (int i = 0; i < count && !is_matching_part; i++) {
|
|
is_matching_part = has_body_parts(
|
|
multipart.get_part(i), text_subtype
|
|
);
|
|
}
|
|
} else if (node is GMime.Part) {
|
|
Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED;
|
|
if (part.content_disposition != null) {
|
|
disposition = part.content_disposition.disposition_type;
|
|
}
|
|
|
|
is_matching_part = (
|
|
disposition != Mime.DispositionType.ATTACHMENT &&
|
|
part.content_type.is_type("text", text_subtype)
|
|
);
|
|
}
|
|
return is_matching_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 Error {
|
|
Part part = new Part(node);
|
|
Mime.ContentType content_type = part.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(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;
|
|
}
|
|
|
|
Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED;
|
|
if (part.content_disposition != null) {
|
|
disposition = part.content_disposition.disposition_type;
|
|
}
|
|
|
|
// Process inline leaf parts
|
|
if (node is GMime.Part &&
|
|
disposition != Mime.DispositionType.ATTACHMENT) {
|
|
|
|
// Assemble body from matching text parts, else 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 (content_type.is_type("text", text_subtype)) {
|
|
body = part.write_to_buffer(
|
|
Part.EncodingConversion.UTF8,
|
|
to_html ? Part.BodyFormatting.HTML : Part.BodyFormatting.NONE
|
|
).to_string();
|
|
} else if (replacer != null &&
|
|
disposition == Mime.DispositionType.INLINE &&
|
|
container_subtype == Mime.MultipartSubtype.MIXED) {
|
|
body = replacer(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 Error {
|
|
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 Error.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 Error.NOT_FOUND if an HTML body is not present.
|
|
*/
|
|
public string? get_html_body(InlinePartReplacer? replacer) throws Error {
|
|
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 Error.NOT_FOUND if a plaintext body is not present.
|
|
*/
|
|
public string? get_plain_body(bool convert_to_html, InlinePartReplacer? replacer)
|
|
throws Error {
|
|
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)
|
|
throws Error {
|
|
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;
|
|
}
|
|
|
|
// UNSPECIFIED disposition means "return all Mime parts"
|
|
internal Gee.List<Part> get_attachments(
|
|
Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED)
|
|
throws Error {
|
|
Gee.List<Part> attachments = new Gee.LinkedList<Part>();
|
|
get_attachments_recursively(attachments, message.get_mime_part(), disposition);
|
|
return attachments;
|
|
}
|
|
|
|
private MailboxAddresses? to_addresses(GMime.InternetAddressList? list)
|
|
throws Error {
|
|
MailboxAddresses? addresses = null;
|
|
if (list != null && list.length() > 0) {
|
|
addresses = new MailboxAddresses.from_gmime(list);
|
|
}
|
|
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<Part> attachments,
|
|
GMime.Object root,
|
|
Mime.DispositionType requested_disposition)
|
|
throws Error {
|
|
if (root is GMime.Multipart) {
|
|
GMime.Multipart multipart = (GMime.Multipart) root;
|
|
int count = multipart.get_count();
|
|
for (int i = 0; i < count; ++i) {
|
|
get_attachments_recursively(attachments, multipart.get_part(i), requested_disposition);
|
|
}
|
|
} else if (root is GMime.MessagePart) {
|
|
GMime.MessagePart messagepart = (GMime.MessagePart) root;
|
|
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(Geary.RFC822.get_format_options(), 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(data);
|
|
part.set_filename((message.get_subject() ?? _("(no subject)")) + ".eml");
|
|
attachments.add(new Part(part));
|
|
}
|
|
|
|
get_attachments_recursively(attachments, message.get_mime_part(),
|
|
requested_disposition);
|
|
} else if (root is GMime.Part) {
|
|
Part part = new Part(root);
|
|
|
|
Mime.DispositionType actual_disposition =
|
|
Mime.DispositionType.UNSPECIFIED;
|
|
if (part.content_disposition != null) {
|
|
actual_disposition = part.content_disposition.disposition_type;
|
|
}
|
|
|
|
if (requested_disposition == Mime.DispositionType.UNSPECIFIED ||
|
|
actual_disposition == requested_disposition) {
|
|
Mime.ContentType content_type = part.content_type;
|
|
|
|
#if WITH_TNEF_SUPPORT
|
|
if (content_type.is_type("application", "vnd.ms-tnef")) {
|
|
GMime.StreamMem stream = new GMime.StreamMem();
|
|
((GMime.Part) root).get_content().write_to_stream(stream);
|
|
ByteArray tnef_data = stream.get_byte_array();
|
|
Ytnef.TNEFStruct tn;
|
|
if (Ytnef.ParseMemory(tnef_data.data, out tn) == 0) {
|
|
for (unowned Ytnef.Attachment? a = tn.starting_attach.next; a != null; a = a.next) {
|
|
attachments.add(new Part(tnef_attachment_to_gmime_part(a)));
|
|
}
|
|
}
|
|
} else
|
|
#endif // WITH_TNEF_SUPPORT
|
|
if (actual_disposition == Mime.DispositionType.ATTACHMENT ||
|
|
(!content_type.is_type("text", "plain") &&
|
|
!content_type.is_type("text", "html"))) {
|
|
// Skip text/plain and text/html parts that are INLINE
|
|
// or UNSPECIFIED, as they will be included in the body
|
|
attachments.add(part);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#if WITH_TNEF_SUPPORT
|
|
private GMime.Part tnef_attachment_to_gmime_part(Ytnef.Attachment a) {
|
|
Ytnef.VariableLength* filenameProp = Ytnef.MAPIFindProperty(a.MAPI, Ytnef.PROP_TAG(Ytnef.PropType.STRING8, Ytnef.PropID.ATTACH_LONG_FILENAME));
|
|
if (filenameProp == Ytnef.MAPI_UNDEFINED) {
|
|
filenameProp = Ytnef.MAPIFindProperty(a.MAPI, Ytnef.PROP_TAG(Ytnef.PropType.STRING8, Ytnef.PropID.DISPLAY_NAME));
|
|
if (filenameProp == Ytnef.MAPI_UNDEFINED) {
|
|
filenameProp = &a.Title;
|
|
}
|
|
}
|
|
string filename = (string) filenameProp.data;
|
|
uint8[] data = Bytes.unref_to_data(new Bytes(a.FileData.data));
|
|
|
|
GMime.Part part = new GMime.Part.with_type("text", "plain");
|
|
part.set_filename(filename);
|
|
part.set_content_type(GMime.ContentType.parse(Geary.RFC822.get_parser_options(), GLib.ContentType.guess(filename, data, null)));
|
|
part.set_content(new GMime.DataWrapper.with_stream(new GMime.StreamMem.with_buffer(data), GMime.ContentEncoding.BINARY));
|
|
return part;
|
|
}
|
|
#endif
|
|
|
|
public Gee.List<Geary.RFC822.Message> get_sub_messages()
|
|
throws Error {
|
|
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<Message> messages,
|
|
GMime.Object root)
|
|
throws Error {
|
|
// 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 Message.from_gmime_message(sub_message));
|
|
} else {
|
|
warning("Corrupt message, possibly bug 769697");
|
|
}
|
|
}
|
|
}
|
|
|
|
private Memory.Buffer message_to_memory_buffer(bool encode_lf,
|
|
RFC822FormatOptions options)
|
|
throws Error {
|
|
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);
|
|
if (encode_lf) {
|
|
stream_filter.add(new GMime.FilterUnix2Dos(false));
|
|
} else {
|
|
stream_filter.add(new GMime.FilterDos2Unix(false));
|
|
}
|
|
if (RFC822FormatOptions.SMTP_FORMAT in options) {
|
|
stream_filter.add(new GMime.FilterSmtpData());
|
|
}
|
|
|
|
var format = Geary.RFC822.get_format_options();
|
|
if (RFC822FormatOptions.SMTP_FORMAT in options) {
|
|
format = format.clone();
|
|
format.add_hidden_header("Bcc");
|
|
}
|
|
|
|
if (message.write_to_stream(format, stream_filter) < 0) {
|
|
throw new Error.FAILED(
|
|
"Unable to write RFC822 message to filter stream"
|
|
);
|
|
}
|
|
|
|
if (stream_filter.flush() != 0) {
|
|
throw new Error.FAILED(
|
|
"Unable to flush RFC822 message to memory stream"
|
|
);
|
|
}
|
|
|
|
if (stream.flush() != 0) {
|
|
throw new Error.FAILED(
|
|
"Unable to flush RFC822 message to memory buffer"
|
|
);
|
|
}
|
|
|
|
return new Memory.ByteBuffer.from_byte_array(byte_array);
|
|
}
|
|
|
|
public string to_string() {
|
|
return message.to_string(Geary.RFC822.get_format_options());
|
|
}
|
|
|
|
/**
|
|
* 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 async GMime.Part body_data_to_part(uint8[] content,
|
|
string? charset,
|
|
string content_type,
|
|
bool is_flowed,
|
|
GLib.Cancellable? cancellable)
|
|
throws GLib.Error {
|
|
GMime.Stream content_stream = new GMime.StreamMem.with_buffer(content);
|
|
if (charset == null) {
|
|
charset = yield Utils.get_best_charset(content_stream, cancellable);
|
|
}
|
|
GMime.StreamFilter filter_stream = new GMime.StreamFilter(content_stream);
|
|
filter_stream.add(new GMime.FilterCharset(UTF8_CHARSET, charset));
|
|
|
|
GMime.ContentEncoding encoding = yield Utils.get_best_encoding(
|
|
filter_stream,
|
|
GMime.EncodingConstraint.7BIT,
|
|
cancellable
|
|
);
|
|
|
|
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.FilterUnix2Dos(false));
|
|
}
|
|
|
|
GMime.ContentType complete_type = GMime.ContentType.parse(
|
|
Geary.RFC822.get_parser_options(),
|
|
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.with_type("text", "plain");
|
|
body_part.set_content_type(complete_type);
|
|
body_part.set_content(body);
|
|
body_part.set_content_encoding(encoding);
|
|
return body_part;
|
|
}
|
|
|
|
}
|