diff --git a/bindings/vapi/gmime-2.6.vapi b/bindings/vapi/gmime-2.6.vapi index 9981421e..2f03d5f8 100644 --- a/bindings/vapi/gmime-2.6.vapi +++ b/bindings/vapi/gmime-2.6.vapi @@ -682,13 +682,13 @@ namespace GMime { [CCode (cname = "g_mime_object_encode")] public virtual void encode (GMime.EncodingConstraint constraint); [CCode (cname = "g_mime_object_get_content_disposition")] - public unowned GMime.ContentDisposition get_content_disposition (); + public unowned GMime.ContentDisposition? get_content_disposition (); [CCode (cname = "g_mime_object_get_content_disposition_parameter")] public unowned string get_content_disposition_parameter (string attribute); [CCode (cname = "g_mime_object_get_content_id")] public unowned string get_content_id (); [CCode (cname = "g_mime_object_get_content_type")] - public unowned GMime.ContentType get_content_type (); + public unowned GMime.ContentType? get_content_type (); [CCode (cname = "g_mime_object_get_content_type_parameter")] public unowned string? get_content_type_parameter (string name); [CCode (cname = "g_mime_object_get_disposition")] diff --git a/bindings/vapi/gmime-2.6/gmime-2.6.metadata b/bindings/vapi/gmime-2.6/gmime-2.6.metadata index e03b4ce6..b565b101 100644 --- a/bindings/vapi/gmime-2.6/gmime-2.6.metadata +++ b/bindings/vapi/gmime-2.6/gmime-2.6.metadata @@ -7,6 +7,8 @@ g_mime_header_list_get_iter.iter is_out="1" g_mime_message_get_date.date is_out="1" g_mime_message_get_date.tz_offset is_out="1" g_mime_message_get_mime_part is_nullable="1" +g_mime_object_get_content_disposition nullable="1" +g_mime_object_get_content_type nullable="1" g_mime_object_get_content_type_parameter nullable="1" g_mime_object_to_string transfer_ownership="1" g_mime_param_next name="get_next" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0640163c..6853191c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -229,6 +229,13 @@ engine/memory/memory-unowned-byte-array-buffer.vala engine/memory/memory-unowned-bytes-buffer.vala engine/memory/memory-unowned-string-buffer.vala +engine/mime/mime-content-disposition.vala +engine/mime/mime-content-parameters.vala +engine/mime/mime-content-type.vala +engine/mime/mime-data-format.vala +engine/mime/mime-disposition-type.vala +engine/mime/mime-error.vala + engine/nonblocking/nonblocking-abstract-semaphore.vala engine/nonblocking/nonblocking-batch.vala engine/nonblocking/nonblocking-concurrent.vala diff --git a/src/client/views/conversation-viewer.vala b/src/client/views/conversation-viewer.vala index fe394ef5..5ef8d018 100644 --- a/src/client/views/conversation-viewer.vala +++ b/src/client/views/conversation-viewer.vala @@ -15,8 +15,16 @@ public class ConversationViewer : Gtk.Box { | Geary.Email.Field.FLAGS | Geary.Email.Field.PREVIEW; - public const string INLINE_MIME_TYPES = - "image/png image/gif image/jpeg image/pjpeg image/bmp image/x-icon image/x-xbitmap image/x-xbm"; + private const string[] INLINE_MIME_TYPES = { + "image/png", + "image/gif", + "image/jpeg", + "image/pjpeg", + "image/bmp", + "image/x-icon", + "image/x-xbitmap", + "image/x-xbm" + }; private const int ATTACHMENT_PREVIEW_SIZE = 50; private const int SELECT_CONVERSATION_TIMEOUT_MSEC = 100; @@ -691,9 +699,26 @@ public class ConversationViewer : Gtk.Box { } } - private static string? inline_image_replacer(string filename, string mimetype, Geary.Memory.Buffer buffer) { - if (!(mimetype in INLINE_MIME_TYPES)) + private static bool is_content_type_supported_inline(Geary.Mime.ContentType content_type) { + foreach (string mime_type in INLINE_MIME_TYPES) { + try { + if (content_type.is_mime_type(mime_type)) + return true; + } catch (Error err) { + debug("Unable to compare MIME type %s: %s", mime_type, err.message); + } + } + + return false; + } + + private static string? inline_image_replacer(string filename, Geary.Mime.ContentType? content_type, + Geary.Mime.ContentDisposition? disposition, Geary.Memory.Buffer buffer) { + if (content_type == null || !is_content_type_supported_inline(content_type)) { + debug("Not displaying %s inline: unsupported Content-Type", content_type.to_string()); + return null; + } // Even if the image doesn't need to be rotated, there's a win here: by reducing the size // of the image at load time, it reduces the amount of work that has to be done to insert @@ -729,7 +754,7 @@ public class ConversationViewer : Gtk.Box { } return "\"%s\"".printf( - filename, DATA_IMAGE_CLASS, assemble_data_uri(mimetype, rotated_image)); + filename, DATA_IMAGE_CLASS, assemble_data_uri(content_type.get_mime_type(), rotated_image)); } // Called by Gdk.PixbufLoader when the image's size has been determined but not loaded yet ... @@ -1809,12 +1834,12 @@ public class ConversationViewer : Gtk.Box { } private static bool should_show_attachment(Geary.Attachment attachment) { - switch (attachment.disposition) { - case Geary.Attachment.Disposition.ATTACHMENT: + switch (attachment.content_disposition.disposition_type) { + case Geary.Mime.DispositionType.ATTACHMENT: return true; - case Geary.Attachment.Disposition.INLINE: - return !(attachment.mime_type in INLINE_MIME_TYPES); + case Geary.Mime.DispositionType.INLINE: + return !is_content_type_supported_inline(attachment.content_type); default: assert_not_reached(); @@ -1876,7 +1901,7 @@ public class ConversationViewer : Gtk.Box { // Set the image preview and insert it into the container. WebKit.DOM.HTMLImageElement img = Util.DOM.select(attachment_table, ".preview img") as WebKit.DOM.HTMLImageElement; - web_view.set_attachment_src(img, attachment.mime_type, attachment.file.get_path(), + web_view.set_attachment_src(img, attachment.content_type, attachment.file.get_path(), ATTACHMENT_PREVIEW_SIZE); attachment_container.append_child(attachment_table); } diff --git a/src/client/views/conversation-web-view.vala b/src/client/views/conversation-web-view.vala index 824ecb7e..dc876d40 100644 --- a/src/client/views/conversation-web-view.vala +++ b/src/client/views/conversation-web-view.vala @@ -222,8 +222,8 @@ public class ConversationWebView : WebKit.WebView { } } - public void set_attachment_src(WebKit.DOM.HTMLImageElement img, string mime_type, string filename, - int maxwidth, int maxheight = -1) { + public void set_attachment_src(WebKit.DOM.HTMLImageElement img, Geary.Mime.ContentType content_type, + string filename, int maxwidth, int maxheight = -1) { if( maxheight == -1 ){ maxheight = maxwidth; } @@ -231,9 +231,9 @@ public class ConversationWebView : WebKit.WebView { try { // If the file is an image, use it. Otherwise get the icon for this mime_type. uint8[] content; - string content_type = ContentType.from_mime_type(mime_type); - string icon_mime_type = mime_type; - if (mime_type.has_prefix("image/")) { + string gio_content_type = ContentType.from_mime_type(content_type.get_mime_type()); + string icon_mime_type = content_type.get_mime_type(); + if (content_type.has_media_type("image")) { // Get a thumbnail for the image. // TODO Generate and save the thumbnail when extracting the attachments rather than // when showing them in the viewer. @@ -245,7 +245,7 @@ public class ConversationWebView : WebKit.WebView { icon_mime_type = "image/png"; } else { // Load the icon for this mime type. - ThemedIcon icon = ContentType.get_icon(content_type) as ThemedIcon; + ThemedIcon icon = ContentType.get_icon(gio_content_type) as ThemedIcon; string icon_filename = IconFactory.instance.lookup_icon(icon.names[0], maxwidth) .get_filename(); FileUtils.get_data(icon_filename, out content); diff --git a/src/engine/api/geary-attachment.vala b/src/engine/api/geary-attachment.vala index 5f083f6c..0aec64d1 100644 --- a/src/engine/api/geary-attachment.vala +++ b/src/engine/api/geary-attachment.vala @@ -11,42 +11,6 @@ */ public abstract class Geary.Attachment : BaseObject { - // NOTE: These values are persisted on disk and should not be modified unless you know what - // you're doing. - public enum Disposition { - ATTACHMENT = 0, - INLINE = 1; - - public static Disposition? from_string(string? str) { - // Returns null to indicate an unknown disposition - if (str == null) { - return null; - } - - switch (str.down()) { - case "attachment": - return ATTACHMENT; - - case "inline": - return INLINE; - - default: - return null; - } - } - - public static Disposition from_int(int i) { - switch (i) { - case INLINE: - return INLINE; - - case ATTACHMENT: - default: - return ATTACHMENT; - } - } - } - /** * An identifier that can be used to locate the {@link Attachment} in an {@link Email}. * @@ -69,9 +33,9 @@ public abstract class Geary.Attachment : BaseObject { public File file { get; private set; } /** - * The MIME type of the {@link Attachment}. + * The {@link Mime.ContentType} of the {@link Attachment}. */ - public string mime_type { get; private set; } + public Mime.ContentType content_type { get; private set; } /** * The file size (in bytes) if the {@link file}. @@ -83,16 +47,16 @@ public abstract class Geary.Attachment : BaseObject { * * See [[https://tools.ietf.org/html/rfc2183]] */ - public Disposition disposition { get; private set; } + public Mime.ContentDisposition content_disposition { get; private set; } - protected Attachment(string id, File file, bool has_supplied_filename, string mime_type, int64 filesize, - Disposition disposition) { + protected Attachment(string id, File file, bool has_supplied_filename, Mime.ContentType content_type, + int64 filesize, Mime.ContentDisposition content_disposition) { this.id = id; this.file = file; this.has_supplied_filename = has_supplied_filename; - this.mime_type = mime_type; + this.content_type = content_type; this.filesize = filesize; - this.disposition = disposition; + this.content_disposition = content_disposition; } } diff --git a/src/engine/imap-db/imap-db-attachment.vala b/src/engine/imap-db/imap-db-attachment.vala index 2bf7b8d2..6c8dda70 100644 --- a/src/engine/imap-db/imap-db-attachment.vala +++ b/src/engine/imap-db/imap-db-attachment.vala @@ -9,10 +9,10 @@ private class Geary.ImapDB.Attachment : Geary.Attachment { private const string ATTACHMENTS_DIR = "attachments"; - protected Attachment(File data_dir, string? filename, string mime_type, int64 filesize, - int64 message_id, int64 attachment_id, Geary.Attachment.Disposition disposition) { + protected Attachment(File data_dir, string? filename, Mime.ContentType content_type, int64 filesize, + int64 message_id, int64 attachment_id, Mime.ContentDisposition content_disposition) { base (generate_id(attachment_id),generate_file(data_dir, message_id, attachment_id, filename), - !String.is_empty(filename), mime_type, filesize, disposition); + !String.is_empty(filename), content_type, filesize, content_disposition); } private static string generate_id(int64 attachment_id) { diff --git a/src/engine/imap-db/imap-db-database.vala b/src/engine/imap-db/imap-db-database.vala index 7af66ae0..16bc3918 100644 --- a/src/engine/imap-db/imap-db-database.vala +++ b/src/engine/imap-db/imap-db-database.vala @@ -256,9 +256,9 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase { try { Geary.RFC822.Message message = new Geary.RFC822.Message.from_parts( new RFC822.Header(header), new RFC822.Text(body)); - Geary.Attachment.Disposition? target_disposition = null; + Mime.DispositionType target_disposition = Mime.DispositionType.UNSPECIFIED; if (message.get_sub_messages().is_empty) - target_disposition = Geary.Attachment.Disposition.INLINE; + target_disposition = Mime.DispositionType.INLINE; Geary.ImapDB.Folder.do_save_attachments_db(cx, id, message.get_attachments(target_disposition), this, null); } catch (Error e) { diff --git a/src/engine/imap-db/imap-db-folder.vala b/src/engine/imap-db/imap-db-folder.vala index e12f8c86..d9b6bc86 100644 --- a/src/engine/imap-db/imap-db-folder.vala +++ b/src/engine/imap-db/imap-db-folder.vala @@ -1874,9 +1874,11 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics { Gee.List list = new Gee.ArrayList(); do { + Mime.ContentDisposition disposition = new Mime.ContentDisposition.simple( + Mime.DispositionType.from_int(results.int_at(4))); list.add(new ImapDB.Attachment(cx.database.db_file.get_parent(), results.string_at(1), - results.nonnull_string_at(2), results.int64_at(3), message_id, results.rowid_at(0), - Geary.Attachment.Disposition.from_int(results.int_at(4)))); + Mime.ContentType.deserialize(results.nonnull_string_at(2)), results.int64_at(3), + message_id, results.rowid_at(0), disposition)); } while (results.next(cancellable)); return list; @@ -1907,6 +1909,14 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics { attachment_data.write_to_stream(stream); // data is null if it's 0 bytes uint filesize = byte_array.len; + // convert into DispositionType enum, which is stored as int + // (legacy code stored UNSPECIFIED as NULL, which is zero, which is ATTACHMENT, so preserve + // this behavior) + Mime.DispositionType disposition_type = Mime.DispositionType.deserialize(disposition, + null); + if (disposition_type == Mime.DispositionType.UNSPECIFIED) + disposition_type = Mime.DispositionType.ATTACHMENT; + // Insert it into the database. Db.Statement stmt = cx.prepare(""" INSERT INTO MessageAttachmentTable (message_id, filename, mime_type, filesize, disposition) @@ -1916,7 +1926,7 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics { stmt.bind_string(1, filename); stmt.bind_string(2, mime_type); stmt.bind_uint(3, filesize); - stmt.bind_int(4, Geary.Attachment.Disposition.from_string(disposition)); + stmt.bind_int(4, disposition_type); int64 attachment_id = stmt.exec_insert(cancellable); diff --git a/src/engine/mime/mime-content-disposition.vala b/src/engine/mime/mime-content-disposition.vala new file mode 100644 index 00000000..edcee532 --- /dev/null +++ b/src/engine/mime/mime-content-disposition.vala @@ -0,0 +1,108 @@ +/* Copyright 2013 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** + * A representation of the RFC 2183 Content-Disposition field. + * + * See [[https://tools.ietf.org/html/rfc2183]] + */ + +public class Geary.Mime.ContentDisposition : Geary.BaseObject { + /** + * Filename parameter name. + * + * See [[https://tools.ietf.org/html/rfc2183#section-2.3]] + */ + public const string FILENAME = "filename"; + + /** + * Creation-Date parameter name. + * + * See [[https://tools.ietf.org/html/rfc2183#section-2.4]] + */ + public const string CREATION_DATE = "creation-date"; + + /** + * Modification-Date parameter name. + * + * See [[https://tools.ietf.org/html/rfc2183#section-2.5]] + */ + public const string MODIFICATION_DATE = "modification-date"; + + /** + * Read-Date parameter name. + * + * See [[https://tools.ietf.org/html/rfc2183#section-2.6]] + */ + public const string READ_DATE = "read-date"; + + /** + * Size parameter name. + * + * See [[https://tools.ietf.org/html/rfc2183#section-2.7]] + */ + public const string SIZE = "size"; + + /** + * The {@link DispositionType}, which is {@link DispositionType.NONE} if not specified. + */ + public DispositionType disposition_type { get; private set; } + + /** + * True if the original DispositionType was unknown. + */ + public bool is_unknown_disposition_type { get; private set; } + + /** + * The original disposition type string. + */ + public string? original_disposition_type_string { get; private set; } + + /** + * Various parameters associated with the content's disposition. + * + * This is never null. Rather, an empty ContentParameters is held if the Content-Type has + * no parameters. + * + * @see FILENAME + * @see CREATION_DATE + * @see MODIFICATION_DATE + * @see READ_DATE + * @see SIZE + */ + public ContentParameters params { get; private set; } + + /** + * Create a Content-Disposition representation + */ + public ContentDisposition(string? disposition, ContentParameters? params) { + bool is_unknown; + disposition_type = DispositionType.deserialize(disposition, out is_unknown); + is_unknown_disposition_type = is_unknown; + original_disposition_type_string = disposition; + this.params = params ?? new ContentParameters(); + } + + /** + * Create a simplified Content-Disposition representation. + */ + public ContentDisposition.simple(DispositionType disposition_type) { + this.disposition_type = disposition_type; + is_unknown_disposition_type = false; + original_disposition_type_string = null; + this.params = new ContentParameters(); + } + + internal ContentDisposition.from_gmime(GMime.ContentDisposition content_disposition) { + bool is_unknown; + disposition_type = DispositionType.deserialize(content_disposition.get_disposition(), + out is_unknown); + is_unknown_disposition_type = is_unknown; + original_disposition_type_string = content_disposition.get_disposition(); + params = new ContentParameters.from_gmime(content_disposition.get_params()); + } +} + diff --git a/src/engine/mime/mime-content-parameters.vala b/src/engine/mime/mime-content-parameters.vala new file mode 100644 index 00000000..21460767 --- /dev/null +++ b/src/engine/mime/mime-content-parameters.vala @@ -0,0 +1,114 @@ +/* Copyright 2013 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** + * Content parameters (for {@link ContentType} and {@link ContentDisposition}). + */ + +public class Geary.Mime.ContentParameters : BaseObject { + public int size { + get { + return params.size; + } + } + + public Gee.Collection attributes { + owned get { + return params.keys; + } + } + + // See get_parameters() for why the keys but not the values are stored case-insensitive + private Gee.HashMap params = new Gee.HashMap( + String.stri_hash, String.stri_equal); + + /** + * Create a mapping of content parameters. + * + * A Gee.Map may be supplied to initialize the parameter attributes (names) and values. + * + * Note that params may be any kind of Map, but they will be stored internally in a Map that + * uses case-insensitive keys. See {@link get_parameters} for more details. + */ + public ContentParameters(Gee.Map? params = null) { + if (params != null && params.size > 0) + Collection.map_set_all(this.params, params); + } + + internal ContentParameters.from_gmime(GMime.Param? gmime_param) { + while (gmime_param != null) { + set_parameter(gmime_param.get_name(), gmime_param.get_value()); + gmime_param = gmime_param.get_next(); + } + } + + /** + * A read-only mapping of parameter attributes (names) and values. + * + * Note that names are stored as case-insensitive tokens. The MIME specification does allow + * for some parameter values to be case-sensitive and so they are stored as such. It is up + * to the caller to use the right comparison method. + * + * @see is_parameter_ci + * @see is_parameter_cs + */ + public Gee.Map get_parameters() { + return params.read_only_view; + } + + /** + * Returns the parameter value for the attribute name. + * + * Returns null if not present. + */ + public string? get_value(string attribute) { + return params.get(attribute); + } + + /** + * Returns true if the attribute has the supplied value (case-insensitive comparison). + * + * @see has_value_ci + */ + public bool has_value_ci(string attribute, string value) { + string? stored = params.get(attribute); + + return (stored != null) ? String.stri_equal(stored, value) : false; + } + + /** + * Returns true if the attribute has the supplied value (case-sensitive comparison). + * + * @see has_value_cs + */ + public bool has_value_cs(string attribute, string value) { + string? stored = params.get(attribute); + + return (stored != null) ? (stored == value) : false; + } + + /** + * Add or replace the parameter. + * + * Returns true if the parameter was added, false, otherwise. + */ + public bool set_parameter(string attribute, string value) { + bool added = !params.has_key(attribute); + params.set(attribute, value); + + return added; + } + + /** + * Removes the parameter. + * + * Returns true if the parameter was present. + */ + public bool remove_parameter(string attribute) { + return params.unset(attribute); + } +} + diff --git a/src/engine/mime/mime-content-type.vala b/src/engine/mime/mime-content-type.vala new file mode 100644 index 00000000..c2d6da81 --- /dev/null +++ b/src/engine/mime/mime-content-type.vala @@ -0,0 +1,199 @@ +/* Copyright 2013 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** + * A representation of an RFC 2045 MIME Content-Type field. + * + * See [[https://tools.ietf.org/html/rfc2045#section-5]] + */ + +public class Geary.Mime.ContentType : Geary.BaseObject { + /* + * MIME wildcard for comparing {@link media_type} and {@link media_subtype}. + * + * @see is_type + */ + public const string WILDCARD = "*"; + + /** + * The type (discrete or concrete) portion of the Content-Type field. + * + * It's highly recommended the caller use the various ''has'' and ''is'' methods when performing + * comparisons rather than direct string operations. + * + * media_type may be {@link WILDCARD}, in which case it matches with any other media_type. + * + * @see has_media_type + */ + public string media_type { get; private set; } + + /** + * The subtype (extension-token or iana-token) portion of the Content-Type field. + * + * It's highly recommended the caller use the various ''has'' and ''is'' methods when performing + * comparisons rather than direct string operations. + * + * media_subtype may be {@link WILDCARD}, in which case it matches with any other media_subtype. + * + * @see has_media_subtype + */ + public string media_subtype { get; private set; } + + /** + * Content parameters, if any, in the Content-Type field. + * + * This is never null. Rather, an empty ContentParameters is held if the Content-Type has + * no parameters. + */ + public ContentParameters params { get; private set; } + + /** + * Create a MIME Content-Type representative object. + */ + public ContentType(string media_type, string media_subtype, ContentParameters? params) { + this.media_type = media_type.strip(); + this.media_subtype = media_subtype.strip(); + this.params = params ?? new ContentParameters(); + } + + internal ContentType.from_gmime(GMime.ContentType content_type) { + media_type = content_type.get_media_type().strip(); + media_subtype = content_type.get_media_subtype().strip(); + params = new ContentParameters.from_gmime(content_type.get_params()); + } + + public static ContentType deserialize(string str) throws MimeError { + // perform a little sanity checking here, as it doesn't appear the GMime constructor has + // any error-reporting at all + if (String.is_empty(str)) + throw new MimeError.PARSE("Empty MIME Content-Type"); + + if (!str.contains("/")) + throw new MimeError.PARSE("Invalid MIME Content-Type: %s", str); + + return new ContentType.from_gmime(new GMime.ContentType.from_string(str)); + } + + /** + * Compares the {@link media_type} with the supplied type. + * + * An asterisk ("*") or {@link WILDCARD) are accepted, which will always return true. + * + * @see is_type + */ + public bool has_media_type(string media_type) { + return (media_type != WILDCARD) ? String.stri_equal(this.media_type, media_type) : true; + } + + /** + * Compares the {@link media_subtype} with the supplied subtype. + * + * An asterisk ("*") or {@link WILDCARD) are accepted, which will always return true. + * + * @see is_type + */ + public bool has_media_subtype(string media_subtype) { + return (media_subtype != WILDCARD) ? String.stri_equal(this.media_subtype, media_subtype) : true; + } + + /** + * Returns the {@link ContentType}'s media content type (its "MIME type"). + * + * This returns the bare MIME content type description lacking all parameters. For example, + * "image/jpeg; name='photo.JPG'" will be returned as "image/jpeg". + * + * @see serialize + */ + public string get_mime_type() { + return "%s/%s".printf(media_type, media_subtype); + } + + /** + * Compares the supplied type and subtype with this instance's. + * + * Asterisks (or {@link WILDCARD}) may be supplied for either field. + * + * @see is_same + */ + public bool is_type(string media_type, string media_subtype) { + return has_media_type(media_type) && has_media_subtype(media_subtype); + } + + /** + * Compares this {@link ContentType} with another instance. + * + * This is slightly different than the notion of "equal to", as it's possible for + * {@link ContentType} to hold {@link WILDCARD}s, which don't imply equality. + * + * @see is_type + */ + public bool is_same(ContentType other) { + return is_type(other.media_type, other.media_subtype); + } + + /** + * Compares the supplied MIME type (i.e. "image/jpeg") with this instance. + * + * As in {@link get_mime_type}, this method is only worried about the media type and subtype + * in the supplied string. Parameters are ignored. + * + * Throws {@link MimeError} if the supplied string doesn't look like a MIME type. + */ + public bool is_mime_type(string mime_type) throws MimeError { + int index = mime_type.index_of_char('/'); + if (index < 0) + throw new MimeError.PARSE("Invalid MIME type: %s", mime_type); + + string mime_media_type = mime_type.substring(0, index).strip(); + + string mime_media_subtype = mime_type.substring(index + 1); + index = mime_media_subtype.index_of_char(';'); + if (index >= 0) + mime_media_subtype = mime_media_subtype.substring(0, index); + mime_media_subtype = mime_media_subtype.strip(); + + if (String.is_empty(mime_media_type) || String.is_empty(mime_media_subtype)) + throw new MimeError.PARSE("Invalid MIME type: %s", mime_type); + + return is_type(mime_media_type, mime_media_subtype); + } + + public string serialize() { + StringBuilder builder = new StringBuilder(); + builder.append_printf("%s/%s", media_type, media_subtype); + + if (params != null && params.size > 0) { + foreach (string attribute in params.attributes) { + string value = params.get_value(attribute); + + switch (DataFormat.get_encoding_requirement(value)) { + case DataFormat.Encoding.QUOTING_OPTIONAL: + builder.append_printf("; %s=%s", attribute, value); + break; + + case DataFormat.Encoding.QUOTING_REQUIRED: + builder.append_printf("; %s=\"%s\"", attribute, value); + break; + + case DataFormat.Encoding.UNALLOWED: + message("Cannot encode ContentType param value %s=\"%s\": unallowed", + attribute, value); + break; + + default: + assert_not_reached(); + } + } + } + + return builder.str; + } + + public string to_string() { + return serialize(); + } +} + diff --git a/src/engine/mime/mime-data-format.vala b/src/engine/mime/mime-data-format.vala new file mode 100644 index 00000000..88ac8553 --- /dev/null +++ b/src/engine/mime/mime-data-format.vala @@ -0,0 +1,45 @@ +/* Copyright 2013 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** + * Utility methods for manipulating and examining data particular to MIME. + */ + +namespace Geary.Mime.DataFormat { + +private const char[] CONTENT_TYPE_TOKEN_SPECIALS = { + '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=' +}; + +public enum Encoding { + QUOTING_REQUIRED, + QUOTING_OPTIONAL, + UNALLOWED +} + +public Encoding get_encoding_requirement(string str) { + if (String.is_empty(str)) + return Encoding.QUOTING_REQUIRED; + + Encoding encoding = Encoding.QUOTING_OPTIONAL; + int index = 0; + for (;;) { + char ch = str[index++]; + if (ch == String.EOS) + break; + + if (ch.iscntrl()) + return Encoding.UNALLOWED; + + // don't return immediately, it's possible unallowed characters may still be ahead + if (ch.isspace() || ch in CONTENT_TYPE_TOKEN_SPECIALS) + encoding = Encoding.QUOTING_REQUIRED; + } + + return encoding; +} + +} diff --git a/src/engine/mime/mime-disposition-type.vala b/src/engine/mime/mime-disposition-type.vala new file mode 100644 index 00000000..da71c7ab --- /dev/null +++ b/src/engine/mime/mime-disposition-type.vala @@ -0,0 +1,87 @@ +/* Copyright 2013 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** + * A representation of a MIME Content-Disposition type field. + * + * Note that NONE only indicates that the Content-Disposition type field was not present. + * RFC 2183 Section 2.8 specifies that unknown type fields should be treated as attachments, + * which is true in this code as well. + * + * These values may be persisted on disk and should not be modified unless you know what + * you're doing. (Legacy code requires that NONE be -1.) + * + * See [[https://tools.ietf.org/html/rfc2183#section-2]] + */ + +public enum Geary.Mime.DispositionType { + UNSPECIFIED = -1, + ATTACHMENT = 0, + INLINE = 1; + + /** + * Convert the disposition-type field into an internal representation. + * + * Empty or blank fields result in {@link UNSPECIFIED}. Unknown fields are converted to + * {@link ATTACHMENT} as per RFC 2183 Section 2.8. However, since the caller may want to + * make a decision about unknown vs. unspecified type fields, is_unknown is returned as well. + */ + public static DispositionType deserialize(string? str, out bool is_unknown) { + is_unknown = false; + + if (String.is_empty_or_whitespace(str)) + return UNSPECIFIED; + + switch (str.down()) { + case "inline": + return INLINE; + + case "attachment": + return ATTACHMENT; + + default: + is_unknown = true; + + return ATTACHMENT; + } + } + + /** + * Returns null if value is {@link UNSPECIFIED} + */ + public string? serialize() { + switch (this) { + case UNSPECIFIED: + return null; + + case ATTACHMENT: + return "attachment"; + + case INLINE: + return "inline"; + + default: + assert_not_reached(); + } + } + + internal static DispositionType from_int(int i) { + switch (i) { + case INLINE: + return INLINE; + + case UNSPECIFIED: + return UNSPECIFIED; + + // see note in class description for why unknown content-dispositions are treated as + // attachments + case ATTACHMENT: + default: + return ATTACHMENT; + } + } +} + diff --git a/src/engine/mime/mime-error.vala b/src/engine/mime/mime-error.vala new file mode 100644 index 00000000..8926e438 --- /dev/null +++ b/src/engine/mime/mime-error.vala @@ -0,0 +1,13 @@ +/* Copyright 2013 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** + * Errors related to {@link Geary.Mime}. + */ + +public errordomain MimeError { + PARSE +} diff --git a/src/engine/rfc822/rfc822-message-data.vala b/src/engine/rfc822/rfc822-message-data.vala index b082330a..b8a803f0 100644 --- a/src/engine/rfc822/rfc822-message-data.vala +++ b/src/engine/rfc822/rfc822-message-data.vala @@ -330,9 +330,13 @@ public class Geary.RFC822.PreviewText : Geary.RFC822.Text { GMime.Parser parser = new GMime.Parser.with_stream(header_stream); GMime.Part? part = parser.construct_part() as GMime.Part; if (part != null) { - is_html = (part.get_content_type().to_string() == "text/html"); + Mime.ContentType? content_type = null; + if (part.get_content_type() != null) { + content_type = new Mime.ContentType.from_gmime(part.get_content_type()); + is_html = content_type.is_type("text", "html"); + charset = content_type.params.get_value("charset"); + } - charset = part.get_content_type_parameter("charset"); encoding = part.get_header("Content-Transfer-Encoding"); } diff --git a/src/engine/rfc822/rfc822-message.vala b/src/engine/rfc822/rfc822-message.vala index 64c6d3b5..7063d67e 100644 --- a/src/engine/rfc822/rfc822-message.vala +++ b/src/engine/rfc822/rfc822-message.vala @@ -9,8 +9,8 @@ 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. */ - public delegate string? InlinePartReplacer(string filename, string mimetype, - Geary.Memory.Buffer buffer); + public delegate string? InlinePartReplacer(string filename, Mime.ContentType? content_type, + Mime.ContentDisposition? disposition, Geary.Memory.Buffer buffer); private const string DEFAULT_ENCODING = "UTF8"; @@ -470,35 +470,41 @@ public class Geary.RFC822.Message : BaseObject { 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 - string? disposition = part.get_disposition(); - if (disposition != null && disposition.down() == "attachment") + if (disposition != null && disposition.disposition_type == Mime.DispositionType.ATTACHMENT) return false; /* Handle text parts that are not attachments * They may have inline disposition, or they may have no disposition specified */ - GMime.ContentType content_type = part.get_content_type(); - if (String.stri_equal(content_type.get_media_type(), "text")) { - if (String.stri_equal(content_type.get_media_subtype(), text_subtype)) { - body = mime_part_to_memory_buffer(part, true, to_html).to_string(); - return true; + Mime.ContentType? content_type = null; + if (part.get_content_type() != null) { + content_type = new Mime.ContentType.from_gmime(part.get_content_type()); + if (content_type.has_media_type("text")) { + if (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; } - - // We were the wrong kind of text part - return false; } // If images have no disposition, they are handled elsewhere; see #7299 - if (disposition == null) + if (disposition == null || disposition.disposition_type == Mime.DispositionType.UNSPECIFIED) return false; - // Hand off to the replacer for processing if (replacer == null) return false; - string? replaced_part = replacer(RFC822.Utils.get_attachment_filename(part), content_type.to_string(), - mime_part_to_memory_buffer(part)); + // Hand off to the replacer for processing + string? replaced_part = replacer(RFC822.Utils.get_attachment_filename(part), content_type, + disposition, mime_part_to_memory_buffer(part)); if (replaced_part != null) body = replaced_part; @@ -642,16 +648,16 @@ public class Geary.RFC822.Message : BaseObject { return null; } - internal Gee.List get_attachments(Geary.Attachment.Disposition? disposition = null) - throws RFC822Error { - // A null disposition means "return all Mime parts recognized by Geary.Attachment.Disposition" + // UNSPECIFIED disposition means "return all Mime parts" + internal Gee.List get_attachments( + Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED) throws RFC822Error { Gee.List attachments = new Gee.ArrayList(); get_attachments_recursively(attachments, message.get_mime_part(), disposition); return attachments; } private void get_attachments_recursively(Gee.List attachments, GMime.Object root, - Geary.Attachment.Disposition? requested_disposition) throws RFC822Error { + 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) { @@ -666,14 +672,15 @@ public class Geary.RFC822.Message : BaseObject { GMime.MessagePart? messagepart = root as GMime.MessagePart; if (messagepart != null) { GMime.Message message = messagepart.get_message(); - Geary.Attachment.Disposition? disposition = Geary.Attachment.Disposition.from_string( - root.get_disposition()); - if (disposition == null) { + 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 = Geary.Attachment.Disposition.ATTACHMENT; + disposition = Mime.DispositionType.ATTACHMENT; } - if (requested_disposition == null || disposition == requested_disposition) { + 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, @@ -695,32 +702,24 @@ public class Geary.RFC822.Message : BaseObject { return; } - Geary.Attachment.Disposition? part_disposition = Geary.Attachment.Disposition.from_string( - part.get_disposition()); - if (part_disposition == null) { - // The part disposition was unknown to Geary.Attachment.Disposition + Mime.DispositionType part_disposition = Mime.DispositionType.deserialize(part.get_disposition(), + null); + if (part_disposition == Mime.DispositionType.UNSPECIFIED) return; - } - GMime.ContentType content_type = part.get_content_type(); - if (part_disposition == Geary.Attachment.Disposition.INLINE && - content_type.get_media_type().down() == "text") { - string subtype = content_type.get_media_subtype().down(); - if (subtype == "html" || subtype == "plain") { - // These are 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 + && content_type.has_media_type("text") + && (content_type.has_media_subtype("html") || content_type.has_media_subtype("plain"))) { + // these are part of the body return; } } - if (requested_disposition == null) { - // Return any attachment whose disposition is recognized by Geary.Attachment.Disposition + // Catch remaining disposition-type matches + if (requested_disposition == Mime.DispositionType.UNSPECIFIED || part_disposition == requested_disposition) attachments.add(part); - return; - } - - if (part_disposition == requested_disposition) { - attachments.add(part); - } } public Gee.List get_sub_messages() { @@ -766,11 +765,14 @@ public class Geary.RFC822.Message : BaseObject { 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", - part.get_content_type().to_string()); + content_type.to_string()); } ByteArray byte_array = new ByteArray(); @@ -780,17 +782,17 @@ public class Geary.RFC822.Message : BaseObject { // Convert encoding to UTF-8. GMime.StreamFilter stream_filter = new GMime.StreamFilter(stream); if (to_utf8) { - string? charset = part.get_content_type_parameter("charset"); + string? charset = (content_type != null) ? content_type.params.get_value("charset") : null; if (String.is_empty(charset)) charset = DEFAULT_ENCODING; stream_filter.add(Geary.RFC822.Utils.create_utf8_filter_charset(charset)); } - string format = part.get_content_type_parameter("format") ?? ""; - bool flowed = (format.down() == "flowed"); - string delsp_par = part.get_content_type_parameter("DelSp") ?? "no"; - bool delsp = (delsp_par.down() == "yes"); + + 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; if (flowed) stream_filter.add(new Geary.RFC822.FilterFlowed(to_html, delsp)); + if (to_html) { if (!flowed) stream_filter.add(new Geary.RFC822.FilterPlain());