Merge branch 'wip/726281-text-attachment-crlf'. Fixes Bug 726281.

This commit is contained in:
Michael James Gratton 2018-05-19 14:59:10 +10:00
commit 440a4d5932
45 changed files with 2732 additions and 1429 deletions

View file

@ -354,6 +354,7 @@ src/engine/rfc822/rfc822-mailbox-address.vala
src/engine/rfc822/rfc822-mailbox-addresses.vala
src/engine/rfc822/rfc822-message-data.vala
src/engine/rfc822/rfc822-message.vala
src/engine/rfc822/rfc822-part.vala
src/engine/rfc822/rfc822-utils.vala
src/engine/rfc822/rfc822.vala
src/engine/smtp/smtp-authenticator.vala
@ -375,7 +376,6 @@ src/engine/state/state-machine.vala
src/engine/state/state-mapping.vala
src/engine/util/util-ascii.vala
src/engine/util/util-collection.vala
src/engine/util/util-converter.vala
src/engine/util/util-files.vala
src/engine/util/util-generic-capabilities.vala
src/engine/util/util-html.vala

View file

@ -275,6 +275,7 @@ engine/rfc822/rfc822-mailbox-addresses.vala
engine/rfc822/rfc822-mailbox-address.vala
engine/rfc822/rfc822-message.vala
engine/rfc822/rfc822-message-data.vala
engine/rfc822/rfc822-part.vala
engine/rfc822/rfc822-utils.vala
engine/smtp/smtp-authenticator.vala
@ -299,7 +300,6 @@ engine/state/state-mapping.vala
engine/util/util-ascii.vala
engine/util/util-collection.vala
engine/util/util-connectivity-manager.vala
engine/util/util-converter.vala
engine/util/util-files.vala
engine/util/util-generic-capabilities.vala
engine/util/util-html.vala

View file

@ -184,7 +184,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
}
} catch (Error error) {
debug("Failed to load icon for attachment '%s': %s",
this.attachment.id,
this.attachment.file.get_path(),
error.message);
}

View file

@ -656,37 +656,40 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
}
}
// This delegate is called from within Geary.RFC822.Message.get_body while assembling the plain
// or HTML document when a non-text MIME part is encountered within a multipart/mixed container.
// If this returns null, the MIME part is dropped from the final returned document; otherwise,
// this returns HTML that is placed into the document in the position where the MIME part was
// found
private string? inline_image_replacer(string? filename, Geary.Mime.ContentType? content_type,
Geary.Mime.ContentDisposition? disposition, string? content_id, Geary.Memory.Buffer buffer) {
if (content_type == null) {
debug("Not displaying inline: no Content-Type");
return null;
}
// This delegate is called from within
// Geary.RFC822.Message.get_body while assembling the plain or
// HTML document when a non-text MIME part is encountered within a
// multipart/mixed container. If this returns null, the MIME part
// is dropped from the final returned document; otherwise, this
// returns HTML that is placed into the document in the position
// where the MIME part was found
private string? inline_image_replacer(Geary.RFC822.Part part) {
Geary.Mime.ContentType content_type = part.get_effective_content_type();
if (content_type.media_type != "image" ||
!this.web_view.can_show_mime_type(content_type.to_string())) {
debug("Not displaying %s inline: unsupported Content-Type", content_type.to_string());
debug("Not displaying %s inline: unsupported Content-Type",
content_type.to_string());
return null;
}
string id = content_id;
string? id = part.content_id;
if (id == null) {
id = REPLACED_CID_TEMPLATE.printf(this.next_replaced_buffer_number++);
}
this.web_view.add_internal_resource(id, buffer);
try {
this.web_view.add_internal_resource(id, part.write_to_buffer());
} catch (Geary.RFC822Error err) {
debug("Failed to get inline buffer: %s", err.message);
return null;
}
// Translators: This string is used as the HTML IMG ALT
// attribute value when displaying an inline image in an email
// that did not specify a file name. E.g. <IMG ALT="Image" ...
string UNKNOWN_FILENAME_ALT_TEXT = _("Image");
string clean_filename = Geary.HTML.escape_markup(
filename ?? UNKNOWN_FILENAME_ALT_TEXT
part.get_clean_filename() ?? UNKNOWN_FILENAME_ALT_TEXT
);
return "<img alt=\"%s\" class=\"%s\" src=\"%s%s\" />".printf(

View file

@ -11,13 +11,6 @@
public abstract class Geary.Attachment : BaseObject {
/**
* An identifier that can be used to locate the {@link Attachment} in an {@link Email}.
*
* @see Email.get_attachment_by_id
*/
public string id { get; private set; }
/**
* The {@link Mime.ContentType} of the {@link Attachment}.
*/
@ -62,31 +55,30 @@ public abstract class Geary.Attachment : BaseObject {
public string? content_filename { get; private set; }
/**
* The on-disk File of the {@link Attachment}.
* The attachment's on-disk File, if any.
*
* This will be null if the attachment has not been saved to disk.
*/
public File file { get; private set; }
public GLib.File? file { get; private set; default = null; }
/**
* The file size (in bytes) if the {@link file}.
*
* This will be -1 if the attachment has not been saved to disk.
*/
public int64 filesize { get; private set; }
public int64 filesize { get; private set; default = -1; }
protected Attachment(string id,
Mime.ContentType content_type,
protected Attachment(Mime.ContentType content_type,
string? content_id,
string? content_description,
Mime.ContentDisposition content_disposition,
string? content_filename,
File file,
int64 filesize) {
this.id = id;
string? content_filename) {
this.content_type = content_type;
this.content_id = content_id;
this.content_description = content_description;
this.content_disposition = content_disposition;
this.content_filename = content_filename;
this.file = file;
this.filesize = filesize;
}
/**
@ -111,7 +103,7 @@ public abstract class Geary.Attachment : BaseObject {
string[] others = {
alt_file_name,
this.content_id,
this.id ?? "attachment",
"attachment",
};
int i = 0;
@ -135,12 +127,12 @@ public abstract class Geary.Attachment : BaseObject {
}
if (name_type == null ||
name_type.is_default() ||
name_type.is_same(Mime.ContentType.ATTACHMENT_DEFAULT) ||
!name_type.is_same(mime_type)) {
// Substitute file name either is of unknown type
// (e.g. it does not have an extension) or is not the
// same type as the declared type, so try to fix it.
if (mime_type.is_default()) {
if (mime_type.is_same(Mime.ContentType.ATTACHMENT_DEFAULT)) {
// Declared type is unknown, see if we can guess
// it. Don't use GFile.query_info however since
// that will attempt to use the filename, which is
@ -162,4 +154,12 @@ public abstract class Geary.Attachment : BaseObject {
return file_name;
}
/**
* Sets the attachment's on-disk location and size.
*/
protected void set_file_info(GLib.File file, int64 file_size) {
this.file = file;
this.filesize = file_size;
}
}

View file

@ -372,25 +372,6 @@ public class Geary.Email : BaseObject {
return message;
}
/**
* Returns the attachment with the given {@link Geary.Attachment.id}.
*
* Requires the REQUIRED_FOR_MESSAGE fields be present; else
* EngineError.INCOMPLETE_MESSAGE is thrown.
*/
public Geary.Attachment? get_attachment_by_id(string attachment_id)
throws EngineError {
if (!fields.fulfills(REQUIRED_FOR_MESSAGE))
throw new EngineError.INCOMPLETE_MESSAGE("Parsed email requires HEADER and BODY");
foreach (Geary.Attachment attachment in attachments) {
if (attachment.id == attachment_id) {
return attachment;
}
}
return null;
}
/**
* Returns the attachment with the given MIME Content ID.
*

View file

@ -1,16 +1,19 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
/*
* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* A Connection represents a connection to an open database. Because SQLite uses a
* synchronous interface, all calls are blocking. Db.Database offers asynchronous queries by
* pooling connections and invoking queries from background threads.
* A Connection represents a connection to an open database.
*
* Connections are associated with a Database. Use Database.open_connection() to create
* one.
* Because SQLite uses a synchronous interface, all calls are
* blocking. Db.Database offers asynchronous queries by pooling
* connections and invoking queries from background threads.
*
* Connections are associated with a Database. Use
* Database.open_connection() to create one.
*
* A Connection will close when its last reference is dropped.
*/
@ -76,10 +79,12 @@ public class Geary.Db.Connection : Geary.Db.Context {
}
check_cancelled("Connection.ctor", cancellable);
try {
throw_on_error("Connection.ctor", Sqlite.Database.open_v2(database.db_file.get_path(),
out db, sqlite_flags, null));
throw_on_error(
"Connection.ctor",
Sqlite.Database.open_v2(database.path, out db, sqlite_flags, null)
);
} catch (DatabaseError derr) {
// don't throw BUSY error for open unless no db object was returned, as it's possible for
// open_v2() to return an error *and* a valid Database object, see:
@ -95,8 +100,8 @@ public class Geary.Db.Connection : Geary.Db.Context {
*
* There is no way to retrieve a result iterator from this call.
*
* This may be called from a TransactionMethod called within exec_transaction() or
* Db.Database.exec_transaction_async().
* This may be called from a TransactionMethod called within
* {@link exec_transaction} or {@link exec_transaction_async}.
*
* See [[http://www.sqlite.org/c3ref/exec.html]]
*/
@ -114,8 +119,8 @@ public class Geary.Db.Connection : Geary.Db.Context {
*
* There is no way to retrieve a result iterator from this call.
*
* This can be called from a TransactionMethod called within exec_transaction() or
* Db.Database.exec_transaction_async().
* This may be called from a TransactionMethod called within
* {@link exec_transaction} or {@link exec_transaction_async}.
*/
public void exec_file(File file, Cancellable? cancellable = null) throws Error {
check_cancelled("Connection.exec_file", cancellable);
@ -148,14 +153,18 @@ public class Geary.Db.Connection : Geary.Db.Context {
public int get_busy_timeout_msec() {
return busy_timeout_msec;
}
/**
* Sets busy timeout time in milliseconds. Zero or a negative value indicates that all
* operations that SQLite returns BUSY will be retried until they complete with success or error.
* Otherwise, after said amount of time has transpired, DatabaseError.BUSY will be thrown.
* Sets busy timeout time in milliseconds.
*
* This is imperative for exec_transaction() and Db.Database.exec_transaction_async(), because
* those calls will throw a DatabaseError.BUSY call immediately if another transaction has
* Zero or a negative value indicates that all operations that
* SQLite returns BUSY will be retried until they complete with
* success or error. Otherwise, after said amount of time has
* transpired, DatabaseError.BUSY will be thrown.
*
* This is imperative for {@link exec_transaction} {@link
* exec_transaction_async}, because those calls will throw a
* DatabaseError.BUSY call immediately if another transaction has
* acquired the reserved or exclusive locks.
*/
public void set_busy_timeout_msec(int busy_timeout_msec) throws Error {
@ -413,13 +422,36 @@ public class Geary.Db.Connection : Geary.Db.Context {
return outcome;
}
/**
* Starts a new asynchronous transaction for this connection.
*
* Asynchronous transactions are handled via background
* threads. The background thread calls {@link exec_transaction};
* see that method for more information about coding a
* transaction. The only caveat is that the {@link
* TransactionMethod} passed to it must be thread-safe.
*
* Throws {@link DatabaseError.OPEN_REQUIRED} if not open.
*/
public async TransactionOutcome exec_transaction_async(TransactionType type,
TransactionMethod cb,
Cancellable? cancellable)
throws Error {
// create job to execute in background thread
TransactionAsyncJob job = new TransactionAsyncJob(
this, type, cb, cancellable
);
this.database.add_async_job(job);
return yield job.wait_for_completion_async();
}
public override Connection? get_connection() {
return this;
}
public string to_string() {
return "[%d] %s".printf(cx_number, database.db_file.get_basename());
return "[%d] %s".printf(cx_number, database.path);
}
}

View file

@ -1,27 +1,52 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
/*
* 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.
*/
/**
* Database represents an SQLite file. Multiple Connections may be opened to against the
* Database.
* Represents a single SQLite database.
*
* Since it's often just more bookkeeping to maintain a single global connection, Database also
* offers a master connection which may be used to perform queries and transactions.
* Each database supports multiple {@link Connection}s that allow SQL
* queries to be executed, however if a single connection is required
* by an app, this class also provides convenience methods to execute
* queries against a common ''master'' connection.
*
* Database also offers asynchronous transactions which work via connection and thread pools.
*
* NOTE: In-memory databases are currently unsupported.
* This class offers a number of asynchronous methods, however since
* SQLite only supports a synchronous API, these are implemented using
* a pool of background threads. Asynchronous transactions are
* available via {@link exec_transaction_async}.
*/
public class Geary.Db.Database : Geary.Db.Context {
/** The path passed to SQLite to open a transient database. */
public const string MEMORY_PATH = "file::memory:?cache=shared";
/** The default number of threaded connections opened. */
public const int DEFAULT_MAX_CONCURRENCY = 4;
public File db_file { get; private set; }
/**
* The database's location on the filesystem.
*
* If null, this is a transient, in-memory database.
*/
public File? file { get; private set; }
/**
* The path passed to Sqlite when opening the database.
*
* This will be the path to the database file on disk for
* persistent databases, else {@link MEMORY_PATH} for transient
* databases.
*/
public string path { get; private set; }
public DatabaseFlags flags { get; private set; }
private bool _is_open = false;
public bool is_open {
get {
@ -36,45 +61,72 @@ public class Geary.Db.Database : Geary.Db.Context {
}
}
}
private Connection? master_connection = null;
private int outstanding_async_jobs = 0;
private ThreadPool<TransactionAsyncJob>? thread_pool = null;
private unowned PrepareConnection? prepare_cb = null;
public Database(File db_file) {
this.db_file = db_file;
/**
* Constructs a new database that is persisted on disk.
*/
public Database.persistent(File db_file) {
this.file = db_file;
this.path = db_file.get_path();
}
/**
* Constructs a new database that is stored in memory only.
*/
public Database.transient() {
this.file = null;
this.path = MEMORY_PATH;
}
~Database() {
// Not thrilled about using lock in a dtor
lock (outstanding_async_jobs) {
assert(outstanding_async_jobs == 0);
}
}
/**
* Opens the Database, creating any files and directories it may need in the process depending
* on the DatabaseFlags.
* Prepares the database for use.
*
* NOTE: A Database may be closed, but the Connections it creates will always be valid as
* they hold a reference to their source Database. To release a Database's resources, drop all
* references to it and its associated Connections, Statements, and Results.
* This will create any needed files and directories, check the
* database's integrity, and so on, depending on the flags passed
* to this method.
*
* NOTE: A Database may be closed, but the Connections it creates
* will always be valid as they hold a reference to their source
* Database. To release a Database's resources, drop all
* references to it and its associated Connections, Statements,
* and Results.
*/
public virtual void open(DatabaseFlags flags, PrepareConnection? prepare_cb,
Cancellable? cancellable = null) throws Error {
public virtual async void open(DatabaseFlags flags,
PrepareConnection? prepare_cb,
Cancellable? cancellable = null)
throws Error {
if (is_open)
return;
this.flags = flags;
this.prepare_cb = prepare_cb;
if ((flags & DatabaseFlags.CREATE_DIRECTORY) != 0) {
File db_dir = db_file.get_parent();
if (!db_dir.query_exists(cancellable))
if (this.file != null && (flags & DatabaseFlags.CREATE_DIRECTORY) != 0) {
GLib.File db_dir = this.file.get_parent();
try {
yield db_dir.query_info_async(
GLib.FileAttribute.STANDARD_TYPE,
GLib.FileQueryInfoFlags.NONE,
GLib.Priority.DEFAULT,
cancellable
);
} catch (GLib.IOError.NOT_FOUND err) {
db_dir.make_directory_with_parents(cancellable);
}
}
if (threadsafe()) {
if (thread_pool == null) {
thread_pool = new ThreadPool<TransactionAsyncJob>.with_owned_data(on_async_job,
@ -83,15 +135,18 @@ public class Geary.Db.Database : Geary.Db.Context {
} else {
warning("SQLite not thread-safe: asynchronous queries will not be available");
}
if ((flags & DatabaseFlags.CHECK_CORRUPTION) != 0)
check_for_corruption(flags, cancellable);
if ((flags & DatabaseFlags.CHECK_CORRUPTION) != 0) {
yield Nonblocking.Concurrent.global.schedule_async(() => {
check_for_corruption(flags, cancellable);
}, cancellable);
}
is_open = true;
}
private void check_for_corruption(DatabaseFlags flags, Cancellable? cancellable) throws Error {
// if the file exists, open a connection and test for corruption by creating a dummy table,
// Open a connection and test for corruption by creating a dummy table,
// adding a row, selecting the row, then dropping the table ... can only do this for
// read-write databases, however
//
@ -100,8 +155,7 @@ public class Geary.Db.Database : Geary.Db.Context {
//
// TODO: Allow the caller to specify the name of the test table, so we're not clobbering
// theirs (however improbable it is to name a table "CorruptionCheckTable")
bool exists = db_file.query_exists(cancellable);
if (exists && (flags & DatabaseFlags.READ_ONLY) == 0) {
if ((flags & DatabaseFlags.READ_ONLY) == 0) {
Connection cx = new Connection(this, Sqlite.OPEN_READWRITE, cancellable);
try {
@ -120,17 +174,15 @@ public class Geary.Db.Database : Geary.Db.Context {
// drop table
cx.exec("DROP TABLE CorruptionCheckTable");
} catch (Error err) {
throw new DatabaseError.CORRUPT("Possible integrity problem discovered in %s: %s",
db_file.get_path(), err.message);
throw new DatabaseError.CORRUPT(
"Possible integrity problem discovered in %s: %s",
this.path,
err.message
);
}
} else if (!exists && (flags & DatabaseFlags.CREATE_FILE) == 0) {
// file doesn't exist and no flag to create it ... that's bad too, might as well
// let them know now
throw new DatabaseError.CORRUPT("Database file %s not found and no CREATE_FILE flag",
db_file.get_path());
}
}
/**
* Closes the Database, releasing any resources it may hold, including the master connection.
*
@ -151,27 +203,41 @@ public class Geary.Db.Database : Geary.Db.Context {
is_open = false;
}
private void check_open() throws Error {
if (!is_open)
throw new DatabaseError.OPEN_REQUIRED("Database %s not open", db_file.get_path());
if (!is_open) {
throw new DatabaseError.OPEN_REQUIRED(
"Database %s not open", this.path
);
}
}
/**
* Throws DatabaseError.OPEN_REQUIRED if not open.
*/
public Connection open_connection(Cancellable? cancellable = null) throws Error {
return internal_open_connection(false, cancellable);
public async Connection open_connection(Cancellable? cancellable = null)
throws Error {
Connection? cx = null;
yield Nonblocking.Concurrent.global.schedule_async(() => {
cx = internal_open_connection(false, cancellable);
}, cancellable);
return cx;
}
private Connection internal_open_connection(bool master, Cancellable? cancellable) throws Error {
check_open();
int sqlite_flags = (flags & DatabaseFlags.READ_ONLY) != 0 ? Sqlite.OPEN_READONLY
int sqlite_flags = (flags & DatabaseFlags.READ_ONLY) != 0
? Sqlite.OPEN_READONLY
: Sqlite.OPEN_READWRITE;
if ((flags & DatabaseFlags.CREATE_FILE) != 0)
sqlite_flags |= Sqlite.OPEN_CREATE;
if (this.file == null) {
sqlite_flags |= SQLITE_OPEN_URI;
}
Connection cx = new Connection(this, sqlite_flags, cancellable);
if (prepare_cb != null)
prepare_cb(cx, master);
@ -238,47 +304,62 @@ public class Geary.Db.Database : Geary.Db.Context {
Cancellable? cancellable = null) throws Error {
return get_master_connection().exec_transaction(type, cb, cancellable);
}
/**
* Asynchronous transactions are handled via background threads using a pool of Connections.
* The background thread calls Connection.exec_transaction(); see that method for more
* information about coding a transaction. The only caveat is that the TransactionMethod
* must be thread-safe.
* Starts a new asynchronous transaction using a new connection.
*
* Throws DatabaseError.OPEN_REQUIRED if not open.
* Asynchronous transactions are handled via background
* threads. The background thread opens a new connection, and
* calls {@link Connection.exec_transaction}; see that method for
* more information about coding a transaction. The only caveat is
* that the {@link TransactionMethod} passed to it must be
* thread-safe.
*
* Throws {@link DatabaseError.OPEN_REQUIRED} if not open.
*/
public async TransactionOutcome exec_transaction_async(TransactionType type, TransactionMethod cb,
Cancellable? cancellable) throws Error {
check_open();
if (thread_pool == null)
throw new DatabaseError.GENERAL("SQLite thread safety disabled, async operations unallowed");
// create job to execute in background thread
TransactionAsyncJob job = new TransactionAsyncJob(type, cb, cancellable);
lock (outstanding_async_jobs) {
outstanding_async_jobs++;
}
thread_pool.add(job);
public async TransactionOutcome exec_transaction_async(TransactionType type,
TransactionMethod cb,
Cancellable? cancellable)
throws Error {
TransactionAsyncJob job = new TransactionAsyncJob(
null, type, cb, cancellable
);
add_async_job(job);
return yield job.wait_for_completion_async();
}
/** Adds the given job to the thread pool. */
internal void add_async_job(TransactionAsyncJob new_job) throws Error {
check_open();
if (this.thread_pool == null) {
throw new DatabaseError.GENERAL(
"SQLite thread safety disabled, async operations unallowed"
);
}
lock (this.outstanding_async_jobs) {
this.outstanding_async_jobs++;
}
this.thread_pool.add(new_job);
}
// This method must be thread-safe.
private void on_async_job(owned TransactionAsyncJob job) {
// *never* use master connection for threaded operations
Connection? cx = null;
Connection? cx = job.cx;
Error? open_err = null;
try {
cx = open_connection();
} catch (Error err) {
open_err = err;
debug("Warning: unable to open database connection to %s, cancelling AsyncJob: %s",
db_file.get_path(), err.message);
if (cx == null) {
try {
cx = internal_open_connection(false, job.cancellable);
} catch (Error err) {
open_err = err;
debug("Warning: unable to open database connection to %s, cancelling AsyncJob: %s",
this.path, err.message);
}
}
if (cx != null)
job.execute(cx);
else

View file

@ -1,25 +1,34 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
/*
* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
private class Geary.Db.TransactionAsyncJob : BaseObject {
internal Connection? cx { get; private set; default = null; }
internal Cancellable cancellable { get; private set; }
private TransactionType type;
private unowned TransactionMethod cb;
private Cancellable cancellable;
private Nonblocking.Event completed;
private TransactionOutcome outcome = TransactionOutcome.ROLLBACK;
private Error? caught_err = null;
public TransactionAsyncJob(TransactionType type, TransactionMethod cb, Cancellable? cancellable) {
public TransactionAsyncJob(Connection? cx,
TransactionType type,
TransactionMethod cb,
Cancellable? cancellable) {
this.cx = cx;
this.type = type;
this.cb = cb;
this.cancellable = cancellable ?? new Cancellable();
completed = new Nonblocking.Event();
this.completed = new Nonblocking.Event();
}
public void cancel() {
cancellable.cancel();
}
@ -82,17 +91,16 @@ private class Geary.Db.TransactionAsyncJob : BaseObject {
return false;
}
// No way to cancel this because the callback thread *must* finish before
// we move on here. Any I/O the thread is doing can still be cancelled
// using our cancel() above.
public async TransactionOutcome wait_for_completion_async()
throws Error {
yield completed.wait_async();
if (caught_err != null)
throw caught_err;
return outcome;
yield this.completed.wait_async();
if (this.caught_err != null)
throw this.caught_err;
return this.outcome;
}
}

View file

@ -1,82 +1,103 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
/*
* 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.
*/
/**
* A SQLite database with a versioned, upgradeable schema.
*
* This class uses the SQLite user version pragma to track the current
* version of a database, and a set of SQL scripts (one per version)
* to manage updating from one version to another. When the database
* is first opened by a call to {@link open}, its current version is
* checked against the set of available scripts, and each available
* version script above the current version is applied in
* order. Derived classes may override the {@link pre_upgrade} and
* {@link post_upgrade} methods to perform additional work before and
* after an upgrade script is executed, and {@link starting_upgrade}
* and {@link completed_upgrade} to be notified of the upgrade process
* starting and finishing.
*/
public class Geary.Db.VersionedDatabase : Geary.Db.Database {
public delegate void WorkCallback();
private static Mutex upgrade_mutex = Mutex();
private static Geary.Nonblocking.Mutex upgrade_mutex =
new Geary.Nonblocking.Mutex();
public File schema_dir { get; private set; }
public VersionedDatabase(File db_file, File schema_dir) {
base (db_file);
/** {@inheritDoc} */
public VersionedDatabase.persistent(File db_file, File schema_dir) {
base.persistent(db_file);
this.schema_dir = schema_dir;
}
/** {@inheritDoc} */
public VersionedDatabase.transient(File schema_dir) {
base.transient();
this.schema_dir = schema_dir;
}
/** Returns the current schema version number of this database. */
public int get_schema_version()
throws GLib.Error {
return get_master_connection().get_user_version_number();
}
/**
* Called by {@link open} if a schema upgrade is required and beginning.
*
* If called by {@link open_background}, this will be called in the context of a background
* thread.
*
* If new_db is set to true, the database is being created from scratch.
*/
protected virtual void starting_upgrade(int current_version, bool new_db) {
}
/**
* Called by {@link open} just before performing a schema upgrade step.
*
* If called by {@link open_background}, this will be called in the context of a background
* thread.
*/
protected virtual void pre_upgrade(int version) {
protected virtual async void pre_upgrade(int version, Cancellable? cancellable)
throws Error {
}
/**
* Called by {@link open} just after performing a schema upgrade step.
*
* If called by {@link open_background}, this will be called in the context of a background
* thread.
*/
protected virtual void post_upgrade(int version) {
protected virtual async void post_upgrade(int version, Cancellable? cancellable)
throws Error {
}
/**
* Called by {@link open} if a schema upgrade was required and has now completed.
*
* If called by {@link open_background}, this will be called in the context of a background
* thread.
*/
protected virtual void completed_upgrade(int final_version) {
}
private File get_schema_file(int db_version) {
return schema_dir.get_child("version-%03d.sql".printf(db_version));
}
/**
* Creates or opens the database, initializing and upgrading the schema.
* Prepares the database for use, initializing and upgrading the schema.
*
* If it's detected that the database has a schema version that's unavailable in the schema
* directory, throws {@link DatabaseError.SCHEMA_VERSION}. Generally this indicates the
* user attempted to load the database with an older version of the application.
* If it's detected that the database has a schema version that's
* unavailable in the schema directory, throws {@link
* DatabaseError.SCHEMA_VERSION}. Generally this indicates the
* user attempted to load the database with an older version of
* the application.
*/
public override void open(DatabaseFlags flags, PrepareConnection? prepare_cb,
Cancellable? cancellable = null) throws Error {
base.open(flags, prepare_cb, cancellable);
public override async void open(DatabaseFlags flags,
PrepareConnection? prepare_cb,
Cancellable? cancellable = null)
throws Error {
yield base.open(flags, prepare_cb, cancellable);
// get Connection for upgrade activity
Connection cx = open_connection(cancellable);
Connection cx = yield open_connection(cancellable);
int db_version = cx.get_user_version_number();
debug("VersionedDatabase.upgrade: current database schema for %s: %d", db_file.get_path(),
db_version);
debug("VersionedDatabase.upgrade: current database schema for %s: %d",
this.path, db_version);
// If the DB doesn't exist yet, the version number will be zero, but also treat negative
// values as new
bool new_db = db_version <= 0;
@ -84,117 +105,137 @@ public class Geary.Db.VersionedDatabase : Geary.Db.Database {
// Initialize new database to version 1 (note the preincrement in the loop below)
if (db_version < 0)
db_version = 0;
// Check for database schemas newer than what's available in the schema directory; this
// happens some times in development or if a user attempts to roll back their version
// of the app without restoring a backup of the database ... since schema is so important
// to database coherency, need to protect against both
//
// Note that this is checking for a schema file for the current version of the database
// (assuming it's version 1 or better); the next check autoincrements to look for the
// *next* version of the database
if (db_version > 0 && !get_schema_file(db_version).query_exists(cancellable)) {
throw new DatabaseError.SCHEMA_VERSION("%s schema %d unknown to current schema plan",
db_file.get_path(), db_version);
if (db_version > 0) {
// Check for database schemas newer than what's available
// in the schema directory; this happens some times in
// development or if a user attempts to roll back their
// version of the app without restoring a backup of the
// database ... since schema is so important to database
// coherency, need to protect against both
//
// Note that this is checking for a schema file for the
// current version of the database (assuming it's version
// 1 or better); the next check autoincrements to look for
// the *next* version of the database
if (!yield exists(get_schema_file(db_version), cancellable)) {
throw new DatabaseError.SCHEMA_VERSION(
"%s schema %d unknown to current schema plan",
this.path, db_version
);
}
}
// Go through all the version scripts in the schema directory and apply each of them.
bool started = false;
for (;;) {
File upgrade_script = get_schema_file(++db_version);
if (!upgrade_script.query_exists(cancellable))
if (!yield exists(upgrade_script, cancellable)) {
break;
}
if (!started) {
starting_upgrade(db_version, new_db);
started = true;
}
// Since these upgrades run in a background thread, there's a possibility they
// can run in parallel. That leads to Geary absolutely taking over the machine,
// with potentially several threads all doing heavy database manipulation at
// once. So, we wrap this bit in a mutex lock so that only one database is
// updating at once. It means overall it might take a bit longer, but it keeps
// things usable in the meantime. See <https://bugzilla.gnome.org/show_bug.cgi?id=724475>.
upgrade_mutex.@lock();
pre_upgrade(db_version);
check_cancelled("VersionedDatabase.open", cancellable);
// Since these upgrades run in a background thread,
// there's a possibility they can run in parallel. That
// leads to Geary absolutely taking over the machine, with
// potentially several threads all doing heavy database
// manipulation at once. So, we wrap this bit in a mutex
// lock so that only one database is updating at once. It
// means overall it might take a bit longer, but it keeps
// things usable in the meantime. See
// <https://bugzilla.gnome.org/show_bug.cgi?id=724475>.
int token = yield VersionedDatabase.upgrade_mutex.claim_async(
cancellable
);
Error? locked_err = null;
try {
debug("Upgrading database to version %d with %s", db_version, upgrade_script.get_path());
cx.exec_transaction(TransactionType.EXCLUSIVE, (cx) => {
cx.exec_file(upgrade_script, cancellable);
cx.set_user_version_number(db_version);
return TransactionOutcome.COMMIT;
}, cancellable);
yield execute_upgrade(
cx, db_version, upgrade_script, cancellable
);
} catch (Error err) {
warning("Error upgrading database to version %d: %s", db_version, err.message);
upgrade_mutex.unlock();
throw err;
locked_err = err;
}
VersionedDatabase.upgrade_mutex.release(ref token);
if (locked_err != null) {
throw locked_err;
}
post_upgrade(db_version);
upgrade_mutex.unlock();
}
if (started)
completed_upgrade(db_version);
}
/**
* Opens the database in a background thread so foreground work can be performed while updating.
*
* Since {@link open} may take a considerable amount of time for a {@link VersionedDatabase},
* background_open() can be used to perform that work in a thread while the calling thread
* "pumps" a {@link WorkCallback} every work_cb_msec milliseconds. In general, this is
* designed for allowing an event queue to execute tasks or update a progress monitor of some
* kind.
*
* Note that the database is not opened while the callback is executing and so it should not
* call into the database (unless it's a call safe to use prior to open).
*
* If work_cb_sec is zero or less, WorkCallback is called continuously, which may or may not be
* desired.
*
* @see open
*/
public void open_background(DatabaseFlags flags, PrepareConnection? prepare_cb,
WorkCallback work_cb, int work_cb_msec, Cancellable? cancellable = null) throws Error {
// use a SpinWaiter to safely wait for the thread to exit while occassionally calling the
// WorkCallback (which can not abort in current impl.) to do foreground work.
Synchronization.SpinWaiter waiter = new Synchronization.SpinWaiter(work_cb_msec, () => {
work_cb();
// continue (never abort)
return true;
});
// do the open in a background thread
Error? thread_err = null;
Thread<bool> thread = new Thread<bool>.try("Geary.Db.VersionedDatabase.open()", () => {
try {
open(flags, prepare_cb, cancellable);
} catch (Error err) {
thread_err = err;
}
// notify the foreground waiter we're done
waiter.notify();
return true;
});
// wait until thread is completed and then dispose of it
waiter.wait();
thread = null;
if (thread_err != null)
throw thread_err;
}
}
private async void execute_upgrade(Connection cx,
int db_version,
GLib.File upgrade_script,
Cancellable? cancellable)
throws Error {
debug("Upgrading database to version %d with %s",
db_version, upgrade_script.get_path());
check_cancelled("VersionedDatabase.open", cancellable);
try {
yield pre_upgrade(db_version, cancellable);
} catch (Error err) {
if (!(err is IOError.CANCELLED)) {
warning("Error executing pre-upgrade for version %d: %s",
db_version, err.message);
}
throw err;
}
check_cancelled("VersionedDatabase.open", cancellable);
try {
yield cx.exec_transaction_async(TransactionType.EXCLUSIVE, (cx) => {
cx.exec_file(upgrade_script, cancellable);
cx.set_user_version_number(db_version);
return TransactionOutcome.COMMIT;
}, cancellable);
} catch (Error err) {
if (!(err is IOError.CANCELLED)) {
warning("Error upgrading database to version %d: %s",
db_version, err.message);
}
throw err;
}
check_cancelled("VersionedDatabase.open", cancellable);
try {
yield post_upgrade(db_version, cancellable);
} catch (Error err) {
if (!(err is IOError.CANCELLED)) {
warning("Error executing post-upgrade for version %d: %s",
db_version, err.message);
}
throw err;
}
}
private File get_schema_file(int db_version) {
return schema_dir.get_child("version-%03d.sql".printf(db_version));
}
private async bool exists(GLib.File target, Cancellable? cancellable) {
bool ret = true;
try {
yield target.query_info_async(
GLib.FileAttribute.STANDARD_TYPE,
GLib.FileQueryInfoFlags.NONE,
GLib.Priority.DEFAULT,
cancellable
);
} catch (Error err) {
ret = false;
}
return ret;
}
}

View file

@ -22,6 +22,9 @@
* [[http://code.google.com/p/sqlheavy/|SQLHeavy]].
*/
// Work around missing const in sqlite3.vapi. See Bug 795627.
extern const int SQLITE_OPEN_URI;
extern int sqlite3_enable_shared_cache(int enabled);
namespace Geary.Db {
@ -107,8 +110,8 @@ private int throw_on_error(Context ctx, string? method, int result, string? raw
}
string location = !String.is_empty(method)
? "(%s %s) ".printf(method, ctx.get_database().db_file.get_path())
: "(%s) ".printf(ctx.get_database().db_file.get_path());
? "(%s %s) ".printf(method, ctx.get_database().path)
: "(%s) ".printf(ctx.get_database().path);
string errmsg = (ctx.get_connection() != null) ? " - %s".printf(ctx.get_connection().db.errmsg()) : "";
string sql;
if (ctx.get_statement() != null)

View file

@ -38,6 +38,20 @@ private class Geary.ImapDB.Account : BaseObject {
private const string SEARCH_OP_VALUE_STARRED = "starred";
private const string SEARCH_OP_VALUE_UNREAD = "unread";
// Storage path names
private const string DB_FILENAME = "geary.db";
private const string ATTACHMENTS_DIR = "attachments";
/**
* Returns the on-disk paths used for storage by this account.
*/
public static void get_imap_db_storage_locations(File user_data_dir, out File db_file,
out File attachments_dir) {
db_file = user_data_dir.get_child(DB_FILENAME);
attachments_dir = user_data_dir.get_child(ATTACHMENTS_DIR);
}
private class FolderReference : Geary.SmartReference {
public Geary.FolderPath path;
@ -248,23 +262,29 @@ private class Geary.ImapDB.Account : BaseObject {
return query;
}
public static void get_imap_db_storage_locations(File user_data_dir, out File db_file,
out File attachments_dir) {
db_file = ImapDB.Database.get_db_file(user_data_dir);
attachments_dir = ImapDB.Attachment.get_attachments_dir(user_data_dir);
}
public async void open_async(File user_data_dir, File schema_dir, Cancellable? cancellable)
throws Error {
if (db != null)
if (this.db != null)
throw new EngineError.ALREADY_OPEN("IMAP database already open");
db = new ImapDB.Database(user_data_dir, schema_dir, upgrade_monitor, vacuum_monitor,
account_information.primary_mailbox.address);
File db_file;
File attachments_dir;
Account.get_imap_db_storage_locations(
user_data_dir, out db_file, out attachments_dir
);
this.db = new ImapDB.Database(
db_file,
schema_dir,
attachments_dir,
upgrade_monitor,
vacuum_monitor,
account_information.primary_mailbox.address
);
try {
yield db.open_async(
yield db.open(
Db.DatabaseFlags.CREATE_DIRECTORY | Db.DatabaseFlags.CREATE_FILE | Db.DatabaseFlags.CHECK_CORRUPTION,
cancellable);
} catch (Error err) {
@ -650,28 +670,30 @@ private class Geary.ImapDB.Account : BaseObject {
// return current if already created
ImapDB.Folder? folder = get_local_folder(path);
if (folder != null) {
// update properties
folder.set_properties(properties);
return folder;
} else {
folder = new Geary.ImapDB.Folder(
db,
path,
db.attachments_path,
contact_store,
account_information.primary_mailbox.address,
folder_id,
properties
);
// build a reference to it
FolderReference folder_ref = new FolderReference(folder, path);
folder_ref.reference_broken.connect(on_folder_reference_broken);
// add to the references table
folder_refs.set(folder_ref.path, folder_ref);
folder.unread_updated.connect(on_unread_updated);
}
// create folder
folder = new Geary.ImapDB.Folder(db, path, contact_store, account_information.primary_mailbox.address, folder_id,
properties);
// build a reference to it
FolderReference folder_ref = new FolderReference(folder, path);
folder_ref.reference_broken.connect(on_folder_reference_broken);
// add to the references table
folder_refs.set(folder_ref.path, folder_ref);
folder.unread_updated.connect(on_unread_updated);
return folder;
}
private void on_folder_reference_broken(Geary.SmartReference reference) {
FolderReference folder_ref = (FolderReference) reference;
@ -706,8 +728,10 @@ private class Geary.ImapDB.Account : BaseObject {
// Ignore any messages that don't have the required fields.
if (partial_ok || row.fields.fulfills(requested_fields)) {
Geary.Email email = row.to_email(new Geary.ImapDB.EmailIdentifier(id, null));
Geary.ImapDB.Folder.do_add_attachments(cx, email, id, cancellable);
Attachment.add_attachments(
cx, this.db.attachments_path, email, id, cancellable
);
Gee.Set<Geary.FolderPath>? folders = do_find_email_folders(cx, id, true, cancellable);
if (folders == null) {
if (folder_blacklist == null || !folder_blacklist.contains(null))
@ -1367,10 +1391,12 @@ private class Geary.ImapDB.Account : BaseObject {
throw new EngineError.INCOMPLETE_MESSAGE(
"Message %s only fulfills %Xh fields (required: %Xh)",
email_id.to_string(), row.fields, required_fields);
email = row.to_email(email_id);
Geary.ImapDB.Folder.do_add_attachments(cx, email, email_id.message_id, cancellable);
Attachment.add_attachments(
cx, this.db.attachments_path, email, email_id.message_id, cancellable
);
return Db.TransactionOutcome.DONE;
}, cancellable);
@ -1530,8 +1556,10 @@ private class Geary.ImapDB.Account : BaseObject {
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
cx, message_id, search_fields, out db_fields, cancellable);
Geary.Email email = row.to_email(new Geary.ImapDB.EmailIdentifier(message_id, null));
Geary.ImapDB.Folder.do_add_attachments(cx, email, message_id, cancellable);
Attachment.add_attachments(
cx, this.db.attachments_path, email, message_id, cancellable
);
Geary.ImapDB.Folder.do_add_email_to_search_table(cx, message_id, email, cancellable);
} catch (Error e) {
// This is a somewhat serious issue since we rely on

View file

@ -1,47 +1,296 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
/*
* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
private class Geary.ImapDB.Attachment : Geary.Attachment {
public const Email.Field REQUIRED_FIELDS = Email.REQUIRED_FOR_MESSAGE;
internal const string NULL_FILE_NAME = "none";
private const string ATTACHMENTS_DIR = "attachments";
private const string NULL_FILE_NAME = "none";
public Attachment(int64 message_id,
int64 attachment_id,
Mime.ContentType content_type,
string? content_id,
string? content_description,
Mime.ContentDisposition content_disposition,
string? content_filename,
File data_dir,
int64 filesize) {
base (generate_id(attachment_id),
content_type,
content_id,
content_description,
content_disposition,
content_filename,
generate_file(data_dir, message_id, attachment_id, content_filename),
filesize);
internal int64 message_id { get; private set; }
private int64 attachment_id = -1;
private Attachment(int64 message_id,
Mime.ContentType content_type,
string? content_id,
string? content_description,
Mime.ContentDisposition content_disposition,
string? content_filename) {
base(
content_type,
content_id,
content_description,
content_disposition,
content_filename
);
this.message_id = message_id;
}
private static string generate_id(int64 attachment_id) {
return "imap-db:%s".printf(attachment_id.to_string());
internal Attachment.from_part(int64 message_id, RFC822.Part part)
throws Error {
Mime.ContentDisposition? disposition = part.content_disposition;
if (disposition == null) {
disposition = new Mime.ContentDisposition.simple(
Geary.Mime.DispositionType.UNSPECIFIED
);
}
this(
message_id,
part.get_effective_content_type(),
part.content_id,
part.content_description,
disposition,
part.get_clean_filename()
);
}
public static File generate_file(File data_dir, int64 message_id, int64 attachment_id,
string? filename) {
return get_attachments_dir(data_dir)
.get_child(message_id.to_string())
.get_child(attachment_id.to_string())
.get_child(filename ?? NULL_FILE_NAME);
internal Attachment.from_row(Geary.Db.Result result, File attachments_dir)
throws Error {
string? content_filename = result.string_for("filename");
if (content_filename == ImapDB.Attachment.NULL_FILE_NAME) {
// Prior to 0.12, Geary would store the untranslated
// string "none" as the filename when none was
// specified by the MIME content disposition. Check
// for that and clean it up.
content_filename = null;
}
Mime.ContentDisposition disposition = new Mime.ContentDisposition.simple(
Mime.DispositionType.from_int(result.int_for("disposition"))
);
this(
result.rowid_for("message_id"),
Mime.ContentType.deserialize(result.nonnull_string_for("mime_type")),
result.string_for("content_id"),
result.string_for("description"),
disposition,
content_filename
);
this.attachment_id = result.rowid_for("id");
set_file_info(
generate_file(attachments_dir), result.int64_for("filesize")
);
}
public static File get_attachments_dir(File data_dir) {
return data_dir.get_child(ATTACHMENTS_DIR);
internal void save(Db.Connection cx,
RFC822.Part part,
GLib.File attachments_dir,
Cancellable? cancellable)
throws Error {
insert_db(cx, cancellable);
try {
save_file(part, attachments_dir, cancellable);
update_db(cx, cancellable);
} catch (Error err) {
// Don't honour the cancellable here, we need to delete
// it.
this.delete(cx, cancellable);
throw err;
}
}
// This isn't async since its only callpaths are via db async
// transactions, which run in independent threads.
internal void delete(Db.Connection cx, Cancellable? cancellable) {
if (this.attachment_id >= 0) {
try {
Db.Statement remove_stmt = cx.prepare(
"DELETE FROM MessageAttachmentTable WHERE id=?");
remove_stmt.bind_rowid(0, this.attachment_id);
remove_stmt.exec();
} catch (Error err) {
debug("Error attempting to remove added attachment row for %s: %s",
this.file.get_path(), err.message);
}
}
if (this.file != null) {
try {
this.file.delete(cancellable);
} catch (Error err) {
debug("Error attempting to remove attachment file %s: %s",
this.file.get_path(), err.message);
}
}
}
private void insert_db(Db.Connection cx, Cancellable? cancellable)
throws Error {
// Insert it into the database.
Db.Statement stmt = cx.prepare("""
INSERT INTO MessageAttachmentTable (message_id, filename, mime_type, filesize, disposition, content_id, description)
VALUES (?, ?, ?, ?, ?, ?, ?)
""");
stmt.bind_rowid(0, this.message_id);
stmt.bind_string(1, this.content_filename);
stmt.bind_string(2, this.content_type.to_string());
stmt.bind_int64(3, 0); // This is updated after saving the file
stmt.bind_int(4, this.content_disposition.disposition_type);
stmt.bind_string(5, this.content_id);
stmt.bind_string(6, this.content_description);
this.attachment_id = stmt.exec_insert(cancellable);
}
// This isn't async since its only callpaths are via db async
// transactions, which run in independent threads
private void save_file(RFC822.Part part,
GLib.File attachments_dir,
Cancellable? cancellable)
throws Error {
if (this.attachment_id < 0) {
throw new IOError.NOT_FOUND("No attachment id assigned");
}
File target = generate_file(attachments_dir);
// create directory, but don't throw exception if already exists
try {
target.get_parent().make_directory_with_parents(cancellable);
} catch (IOError ioe) {
// fall through if already exists
if (!(ioe is IOError.EXISTS))
throw ioe;
}
// Delete any existing file now since we might not be creating
// it again below.
try {
target.delete(cancellable);
} catch (IOError err) {
// All good
}
GLib.OutputStream target_stream = target.create(
FileCreateFlags.NONE, cancellable
);
GMime.Stream stream = new Geary.Stream.MimeOutputStream(
target_stream
);
stream = new GMime.StreamBuffer(
stream, GMime.StreamBufferMode.BLOCK_WRITE
);
part.write_to_stream(stream);
// Using the stream's length is a bit of a hack, but at
// least on one system we are getting 0 back for the file
// size if we use target.query_info().
int64 file_size = stream.length();
stream.close();
set_file_info(target, file_size);
}
private void update_db(Db.Connection cx, Cancellable? cancellable)
throws Error {
// Update the file size now we know what it is
Db.Statement stmt = cx.prepare("""
UPDATE MessageAttachmentTable
SET filesize = ?
WHERE id = ?
""");
stmt.bind_int64(0, this.filesize);
stmt.bind_rowid(1, this.attachment_id);
stmt.exec(cancellable);
}
private GLib.File generate_file(GLib.File attachments_dir) {
return attachments_dir
.get_child(this.message_id.to_string())
.get_child(this.attachment_id.to_string())
.get_child(this.content_filename ?? NULL_FILE_NAME);
}
internal static Gee.List<Attachment> save_attachments(Db.Connection cx,
GLib.File attachments_path,
int64 message_id,
Gee.List<RFC822.Part> attachments,
Cancellable? cancellable)
throws Error {
Gee.List<Attachment> list = new Gee.LinkedList<Attachment>();
foreach (RFC822.Part part in attachments) {
Attachment attachment = new Attachment.from_part(message_id, part);
attachment.save(cx, part, attachments_path, cancellable);
list.add(attachment);
}
return list;
}
internal static void delete_attachments(Db.Connection cx,
GLib.File attachments_path,
int64 message_id,
Cancellable? cancellable = null)
throws Error {
Gee.List<Attachment>? attachments = list_attachments(
cx, attachments_path, message_id, cancellable
);
foreach (Attachment attachment in attachments) {
attachment.delete(cx, cancellable);
}
// Ensure they're dead, Jim.
Db.Statement stmt = new Db.Statement(cx, """
DELETE FROM MessageAttachmentTable WHERE message_id = ?
""");
stmt.bind_rowid(0, message_id);
stmt.exec();
}
// XXX this really should be a member of some internal
// ImapDB.Email class.
internal static void add_attachments(Db.Connection cx,
GLib.File attachments_path,
Geary.Email email,
int64 message_id,
Cancellable? cancellable = null)
throws Error {
if (email.fields.fulfills(ImapDB.Attachment.REQUIRED_FIELDS)) {
email.add_attachments(
list_attachments(
cx, attachments_path, message_id, cancellable
)
);
}
}
internal static Gee.List<Attachment> list_attachments(Db.Connection cx,
GLib.File attachments_path,
int64 message_id,
Cancellable? cancellable)
throws Error {
Db.Statement stmt = cx.prepare("""
SELECT *
FROM MessageAttachmentTable
WHERE message_id = ?
ORDER BY id
""");
stmt.bind_rowid(0, message_id);
Db.Result results = stmt.exec(cancellable);
Gee.List<Attachment> list = new Gee.LinkedList<Attachment>();
while (!results.finished) {
list.add(new ImapDB.Attachment.from_row(results, attachments_path));
results.next(cancellable);
}
return list;
}
}

View file

@ -1,4 +1,5 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
/*
* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
@ -7,40 +8,39 @@
extern int sqlite3_unicodesn_register_tokenizer(Sqlite.Database db);
private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
private const string DB_FILENAME = "geary.db";
internal GLib.File attachments_path;
private const int OPEN_PUMP_EVENT_LOOP_MSEC = 100;
private ProgressMonitor upgrade_monitor;
private ProgressMonitor vacuum_monitor;
private string account_owner_email;
private bool new_db = false;
private Cancellable gc_cancellable = new Cancellable();
public Database(File db_dir, File schema_dir, ProgressMonitor upgrade_monitor,
ProgressMonitor vacuum_monitor, string account_owner_email) {
base (get_db_file(db_dir), schema_dir);
public Database(GLib.File db_file,
GLib.File schema_dir,
GLib.File attachments_path,
ProgressMonitor upgrade_monitor,
ProgressMonitor vacuum_monitor,
string account_owner_email) {
base.persistent(db_file, schema_dir);
this.attachments_path = attachments_path;
this.upgrade_monitor = upgrade_monitor;
this.vacuum_monitor = vacuum_monitor;
// Update to use all addresses on the account. Bug 768779
this.account_owner_email = account_owner_email;
}
public static File get_db_file(File db_dir) {
return db_dir.get_child(DB_FILENAME);
}
/**
* Opens the ImapDB database.
*
* This should only be done from the main thread, as it is designed to pump the event loop
* while the database is being opened and updated.
* Prepares the ImapDB database for use.
*/
public async void open_async(Db.DatabaseFlags flags, Cancellable? cancellable) throws Error {
open_background(flags, on_prepare_database_connection, pump_event_loop,
OPEN_PUMP_EVENT_LOOP_MSEC, cancellable);
public new async void open(Db.DatabaseFlags flags, Cancellable? cancellable)
throws Error {
yield base.open(flags, on_prepare_database_connection, cancellable);
// Tie user-supplied Cancellable to internal Cancellable, which is used when close() is
// called
if (cancellable != null)
@ -61,8 +61,9 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
try {
yield gc.vacuum_async(gc_cancellable);
} catch (Error err) {
message("Vacuum of IMAP database %s failed: %s", db_file.get_path(), err.message);
message(
"Vacuum of IMAP database %s failed: %s", this.path, err.message
);
throw err;
} finally {
if (vacuum_monitor.is_in_progress)
@ -85,7 +86,8 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
try {
gc.reap_async.end(result);
} catch (Error err) {
message("Garbage collection of IMAP database %s failed: %s", db_file.get_path(), err.message);
message("Garbage collection of IMAP database %s failed: %s",
this.path, err.message);
}
}
@ -99,140 +101,133 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
base.close(cancellable);
}
private void pump_event_loop() {
while (MainContext.default().pending())
MainContext.default().iteration(true);
}
protected override void starting_upgrade(int current_version, bool new_db) {
this.new_db = new_db;
// can't call the ProgressMonitor directly, as it's hooked up to signals that expect to be
// called in the foreground thread, so use the Idle loop for this
Idle.add(() => {
// don't use upgrade_monitor for new databases, as the upgrade should be near-
// instantaneous. Also, there's some issue with GTK when starting the progress
// monitor while GtkDialog's are in play:
// https://bugzilla.gnome.org/show_bug.cgi?id=726269
if (!new_db && !upgrade_monitor.is_in_progress)
upgrade_monitor.notify_start();
return false;
});
// don't use upgrade_monitor for new databases, as the upgrade should be near-
// instantaneous. Also, there's some issue with GTK when starting the progress
// monitor while GtkDialog's are in play:
// https://bugzilla.gnome.org/show_bug.cgi?id=726269
if (!new_db && !upgrade_monitor.is_in_progress) {
upgrade_monitor.notify_start();
}
}
protected override void completed_upgrade(int final_version) {
// see starting_upgrade() for explanation why this is done in Idle loop
Idle.add(() => {
if (!new_db && upgrade_monitor.is_in_progress)
upgrade_monitor.notify_finish();
return false;
});
if (!new_db && upgrade_monitor.is_in_progress) {
upgrade_monitor.notify_finish();
}
}
protected override void post_upgrade(int version) {
protected async override void post_upgrade(int version,
Cancellable? cancellable)
throws Error {
switch (version) {
case 5:
post_upgrade_populate_autocomplete();
yield post_upgrade_populate_autocomplete(cancellable);
break;
case 6:
post_upgrade_encode_folder_names();
yield post_upgrade_encode_folder_names(cancellable);
break;
case 11:
post_upgrade_add_search_table();
yield post_upgrade_add_search_table(cancellable);
break;
case 12:
post_upgrade_populate_internal_date_time_t();
yield post_upgrade_populate_internal_date_time_t(cancellable);
break;
case 13:
post_upgrade_populate_additional_attachments();
yield post_upgrade_populate_additional_attachments(cancellable);
break;
case 14:
post_upgrade_expand_page_size();
yield post_upgrade_expand_page_size(cancellable);
break;
case 15:
post_upgrade_fix_localized_internaldates();
yield post_upgrade_fix_localized_internaldates(cancellable);
break;
case 18:
post_upgrade_populate_internal_date_time_t();
yield post_upgrade_populate_internal_date_time_t(cancellable);
break;
case 19:
post_upgrade_validate_contacts();
yield post_upgrade_validate_contacts(cancellable);
break;
case 22:
post_upgrade_rebuild_attachments();
yield post_upgrade_rebuild_attachments(cancellable);
break;
case 23:
post_upgrade_add_tokenizer_table();
yield post_upgrade_add_tokenizer_table(cancellable);
break;
}
}
// Version 5.
private void post_upgrade_populate_autocomplete() {
try {
Db.Result result = query("SELECT sender, from_field, to_field, cc, bcc FROM MessageTable");
while (!result.finished) {
MessageAddresses message_addresses =
private async void post_upgrade_populate_autocomplete(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
Db.Result result = cx.query(
"SELECT sender, from_field, to_field, cc, bcc FROM MessageTable"
);
while (!result.finished && !cancellable.is_cancelled()) {
MessageAddresses message_addresses =
new MessageAddresses.from_result(account_owner_email, result);
foreach (Contact contact in message_addresses.contacts) {
do_update_contact(get_master_connection(), contact, null);
foreach (Contact contact in message_addresses.contacts) {
do_update_contact(cx, contact, null);
}
result.next();
}
result.next();
}
} catch (Error err) {
debug("Error populating autocompletion table during upgrade to database schema 5");
}
return Geary.Db.TransactionOutcome.COMMIT;
}, cancellable);
}
// Version 6.
private void post_upgrade_encode_folder_names() {
try {
Db.Result select = query("SELECT id, name FROM FolderTable");
while (!select.finished) {
int64 id = select.int64_at(0);
string encoded_name = select.nonnull_string_at(1);
try {
string canonical_name = Geary.ImapUtf7.imap_utf7_to_utf8(encoded_name);
Db.Statement update = prepare("UPDATE FolderTable SET name=? WHERE id=?");
update.bind_string(0, canonical_name);
update.bind_int64(1, id);
update.exec();
} catch (Error e) {
debug("Error renaming folder %s to its canonical representation: %s", encoded_name, e.message);
private async void post_upgrade_encode_folder_names(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
Db.Result select = cx.query("SELECT id, name FROM FolderTable");
while (!select.finished && !cancellable.is_cancelled()) {
int64 id = select.int64_at(0);
string encoded_name = select.nonnull_string_at(1);
try {
string canonical_name = Geary.ImapUtf7.imap_utf7_to_utf8(encoded_name);
Db.Statement update = cx.prepare(
"UPDATE FolderTable SET name=? WHERE id=?"
);
update.bind_string(0, canonical_name);
update.bind_int64(1, id);
update.exec();
} catch (Error e) {
debug("Error renaming folder %s to its canonical representation: %s", encoded_name, e.message);
}
select.next();
}
select.next();
}
} catch (Error e) {
debug("Error decoding folder names during upgrade to database schema 6: %s", e.message);
}
return Geary.Db.TransactionOutcome.COMMIT;
}, cancellable);
}
// Version 11.
private void post_upgrade_add_search_table() {
try {
string stemmer = find_appropriate_search_stemmer();
debug("Creating search table using %s stemmer", stemmer);
// This can't go in the .sql file because its schema (the stemmer
// algorithm) is determined at runtime.
exec("""
CREATE VIRTUAL TABLE MessageSearchTable USING fts4(
private async void post_upgrade_add_search_table(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
string stemmer = find_appropriate_search_stemmer();
debug("Creating search table using %s stemmer", stemmer);
// This can't go in the .sql file because its schema (the stemmer
// algorithm) is determined at runtime.
cx.exec("""
CREATE VIRTUAL TABLE MessageSearchTable USING fts4(
body,
attachment,
subject,
@ -240,16 +235,15 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
receivers,
cc,
bcc,
tokenize=unicodesn "stemmer=%s",
prefix="2,4,6,8,10",
);
""".printf(stemmer));
} catch (Error e) {
error("Error creating search table: %s", e.message);
}
""".printf(stemmer));
return Geary.Db.TransactionOutcome.COMMIT;
}, cancellable);
}
private string find_appropriate_search_stemmer() {
// Unfortunately, the stemmer library only accepts the full language
// name for the stemming algorithm. This translates between the user's
@ -276,27 +270,30 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
case "tr": return "turkish";
}
}
// Default to English because it seems to be on average the language
// most likely to be present in emails, regardless of the user's
// language setting. This is not an exact science, and search results
// should be ok either way in most cases.
return "english";
}
// Versions 12 and 18.
private void post_upgrade_populate_internal_date_time_t() {
try {
exec_transaction(Db.TransactionType.RW, (cx) => {
Db.Result select = cx.query("SELECT id, internaldate FROM MessageTable");
private async void
post_upgrade_populate_internal_date_time_t(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
Db.Result select = cx.query(
"SELECT id, internaldate FROM MessageTable"
);
while (!select.finished) {
int64 id = select.rowid_at(0);
string? internaldate = select.string_at(1);
try {
time_t as_time_t = (internaldate != null ?
Geary.Imap.InternalDate.decode(internaldate).to_time_t() : -1);
Db.Statement update = cx.prepare(
"UPDATE MessageTable SET internaldate_time_t=? WHERE id=?");
update.bind_int64(0, (int64) as_time_t);
@ -306,22 +303,19 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
debug("Error converting internaldate '%s' to time_t: %s",
internaldate, e.message);
}
select.next();
}
return Db.TransactionOutcome.COMMIT;
});
} catch (Error e) {
debug("Error populating internaldate_time_t column during upgrade to database schema 12: %s",
e.message);
}
}, cancellable);
}
// Version 13.
private void post_upgrade_populate_additional_attachments() {
try {
exec_transaction(Db.TransactionType.RW, (cx) => {
private async void
post_upgrade_populate_additional_attachments(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
Db.Statement stmt = cx.prepare("""
SELECT id, header, body
FROM MessageTable
@ -330,69 +324,74 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
stmt.bind_int(0, Geary.Email.REQUIRED_FOR_MESSAGE);
stmt.bind_int(1, Geary.Email.REQUIRED_FOR_MESSAGE);
Db.Result select = stmt.exec();
while (!select.finished) {
int64 id = select.rowid_at(0);
Geary.Memory.Buffer header = select.string_buffer_at(1);
Geary.Memory.Buffer body = select.string_buffer_at(2);
try {
Geary.RFC822.Message message = new Geary.RFC822.Message.from_parts(
new RFC822.Header(header), new RFC822.Text(body));
Mime.DispositionType target_disposition = Mime.DispositionType.UNSPECIFIED;
if (message.get_sub_messages().is_empty)
target_disposition = Mime.DispositionType.INLINE;
Geary.ImapDB.Folder.do_save_attachments_db(cx, id,
message.get_attachments(target_disposition), this, null);
Attachment.save_attachments(
cx,
this.attachments_path,
id,
message.get_attachments(target_disposition),
null
);
} catch (Error e) {
debug("Error fetching inline Mime parts: %s", e.message);
}
select.next();
}
// additionally, because this schema change (and code changes as well) introduces
// two new types of attachments as well as processing for all MIME text sections
// of messages (not just the first one), blow away the search table and let the
// search indexer start afresh
cx.exec("DELETE FROM MessageSearchTable");
return Db.TransactionOutcome.COMMIT;
});
} catch (Error e) {
debug("Error populating old inline attachments during upgrade to database schema 13: %s",
e.message);
}
}, cancellable);
}
// Version 14.
private void post_upgrade_expand_page_size() {
try {
// When the MessageSearchTable is first touched, SQLite seems to
// read the whole table into memory (or an awful lot of data,
// either way). This was causing slowness when Geary first started
// and checked for any messages not yet in the search table. With
// the database's page_size set to 4096, the reads seem to happen
// about 2 orders of magnitude quicker, probably because 4096
// matches the default filesystem block size and/or Linux's default
// memory page size. With this set, the full read into memory is
// barely noticeable even on slow machines.
// NOTE: these can't be in the .sql file itself because they must
// be back to back, outside of a transaction.
exec("""
PRAGMA page_size = 4096;
VACUUM;
""");
} catch (Error e) {
debug("Error bumping page_size or vacuuming database; performance may be degraded: %s",
e.message);
}
private async void post_upgrade_expand_page_size(Cancellable? cancellable)
throws Error {
// When the MessageSearchTable is first touched,
// SQLite seems to read the whole table into memory
// (or an awful lot of data, either way). This was
// causing slowness when Geary first started and
// checked for any messages not yet in the search
// table. With the database's page_size set to 4096,
// the reads seem to happen about 2 orders of
// magnitude quicker, probably because 4096 matches
// the default filesystem block size and/or Linux's
// default memory page size. With this set, the full
// read into memory is barely noticeable even on slow
// machines.
// NOTE: these can't be in the .sql file itself because
// they must be back to back, outside of a transaction
Geary.Db.Connection cx = yield open_connection();
yield Nonblocking.Concurrent.global.schedule_async(() => {
cx.exec("""
PRAGMA page_size = 4096;
VACUUM;
""");
}, cancellable);
}
// Version 15
private void post_upgrade_fix_localized_internaldates() {
try {
exec_transaction(Db.TransactionType.RW, (cx) => {
private async void
post_upgrade_fix_localized_internaldates(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
Db.Statement stmt = cx.prepare("""
SELECT id, internaldate, fields
FROM MessageTable
@ -413,7 +412,7 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
debug("Invalid INTERNALDATE \"%s\" found at row %s in %s: %s",
internaldate != null ? internaldate : "(null)",
invalid_id.to_string(), db_file.get_path(), err.message);
invalid_id.to_string(), this.path, err.message);
invalid_ids.set(invalid_id, (Geary.Email.Field) results.int_at(2));
}
@ -439,19 +438,15 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
// reuse statment, overwrite invalid_id, fields only
stmt.reset(Db.ResetScope.SAVE_BINDINGS);
}
return Db.TransactionOutcome.COMMIT;
});
} catch (Error err) {
debug("Error fixing INTERNALDATES during upgrade to schema 15 for %s: %s",
db_file.get_path(), err.message);
}
}, cancellable);
}
// Version 19.
private void post_upgrade_validate_contacts() {
try {
exec_transaction(Db.TransactionType.RW, (cx) => {
private async void post_upgrade_validate_contacts(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
Db.Result result = cx.query("SELECT id, email FROM ContactTable");
while (!result.finished) {
string email = result.string_at(1);
@ -467,16 +462,13 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
}
return Db.TransactionOutcome.COMMIT;
});
} catch (Error e) {
debug("Error fixing up contacts table: %s", e.message);
}
}, cancellable);
}
// Version 22
private void post_upgrade_rebuild_attachments() {
try {
exec_transaction(Db.TransactionType.RW, (cx) => {
private async void post_upgrade_rebuild_attachments(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
Db.Statement stmt = cx.prepare("""
SELECT id, header, body
FROM MessageTable
@ -488,73 +480,75 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
Db.Result results = stmt.exec();
if (results.finished)
return Db.TransactionOutcome.ROLLBACK;
do {
int64 message_id = results.rowid_at(0);
Geary.Memory.Buffer header = results.string_buffer_at(1);
Geary.Memory.Buffer body = results.string_buffer_at(2);
Geary.RFC822.Message message;
try {
message = new Geary.RFC822.Message.from_parts(
new RFC822.Header(header), new RFC822.Text(body));
} catch (Error err) {
debug("Error decoding message: %s", err.message);
continue;
}
// build a list of attachments in the message itself
Gee.List<GMime.Part> msg_attachments = message.get_attachments();
// delete all attachments for this message
Gee.List<RFC822.Part> msg_attachments =
message.get_attachments();
try {
Geary.ImapDB.Folder.do_delete_attachments(cx, message_id);
Attachment.delete_attachments(
cx, this.attachments_path, message_id
);
} catch (Error err) {
debug("Error deleting existing attachments: %s", err.message);
debug("Error deleting existing attachments: %s",
err.message);
continue;
}
// rebuild all
try {
Geary.ImapDB.Folder.do_save_attachments_db(cx, message_id, msg_attachments,
this, null);
Attachment.save_attachments(
cx,
this.attachments_path,
message_id,
msg_attachments,
null
);
} catch (Error err) {
debug("Error saving attachments: %s", err.message);
// fallthrough
}
} while (results.next());
// rebuild search table due to potentially new attachments
cx.exec("DELETE FROM MessageSearchTable");
return Db.TransactionOutcome.COMMIT;
});
} catch (Error e) {
debug("Error populating old inline attachments during upgrade to database schema 13: %s",
e.message);
}
}, cancellable);
}
// Version 23
private void post_upgrade_add_tokenizer_table() {
try {
string stemmer = find_appropriate_search_stemmer();
debug("Creating tokenizer table using %s stemmer", stemmer);
// These can't go in the .sql file because its schema (the stemmer
// algorithm) is determined at runtime.
exec("""
CREATE VIRTUAL TABLE TokenizerTable USING fts3tokenize(
unicodesn,
"stemmer=%s"
);
""".printf(stemmer));
} catch (Error e) {
error("Error creating tokenizer table: %s", e.message);
}
private async void post_upgrade_add_tokenizer_table(Cancellable? cancellable)
throws Error {
yield exec_transaction_async(Db.TransactionType.RW, (cx) => {
string stemmer = find_appropriate_search_stemmer();
debug("Creating tokenizer table using %s stemmer", stemmer);
// These can't go in the .sql file because its schema (the stemmer
// algorithm) is determined at runtime.
cx.exec("""
CREATE VIRTUAL TABLE TokenizerTable USING fts3tokenize(
unicodesn,
"stemmer=%s"
);
""".printf(stemmer));
return Db.TransactionOutcome.COMMIT;
}, cancellable);
}
/**
@ -608,5 +602,5 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
cx.set_synchronous(Db.SynchronousMode.NORMAL);
sqlite3_unicodesn_register_tokenizer(cx.db);
}
}
}

View file

@ -82,9 +82,10 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics {
}
protected int manual_ref_count { get; protected set; }
private ImapDB.Database db;
private Geary.Db.Database db;
private Geary.FolderPath path;
private GLib.File attachments_path;
private ContactStore contact_store;
private string account_owner_email;
private int64 folder_id;
@ -102,14 +103,16 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics {
*/
public signal void unread_updated(Gee.Map<ImapDB.EmailIdentifier, bool> unread_status);
internal Folder(ImapDB.Database db,
internal Folder(Geary.Db.Database db,
Geary.FolderPath path,
GLib.File attachments_path,
ContactStore contact_store,
string account_owner_email,
int64 folder_id,
Geary.Imap.FolderProperties properties) {
this.db = db;
this.path = path;
this.attachments_path = attachments_path;
this.contact_store = contact_store;
// Update to use all addresses on the account. Bug 768779
this.account_owner_email = account_owner_email;
@ -1449,8 +1452,15 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics {
// write out attachments, if any
// TODO: Because this involves saving files, it potentially means holding up access to the
// database while they're being written; may want to do this outside of transaction.
if (email.fields.fulfills(Attachment.REQUIRED_FIELDS))
do_save_attachments(cx, message_id, email.get_message().get_attachments(), cancellable);
if (email.fields.fulfills(Attachment.REQUIRED_FIELDS)) {
Attachment.save_attachments(
cx,
this.attachments_path,
message_id,
email.get_message().get_attachments(),
cancellable
);
}
do_add_email_to_search_table(cx, message_id, email, cancellable);
@ -1591,25 +1601,14 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics {
"Message %s in folder %s only fulfills %Xh fields (required: %Xh)",
location.email_id.to_string(), to_string(), row.fields, required_fields);
}
Geary.Email email = row.to_email(location.email_id);
return do_add_attachments(cx, email, location.message_id, cancellable);
}
internal static Geary.Email do_add_attachments(Db.Connection cx, Geary.Email email,
int64 message_id, Cancellable? cancellable = null) throws Error {
// Add attachments if available
if (email.fields.fulfills(ImapDB.Attachment.REQUIRED_FIELDS)) {
Gee.List<Geary.Attachment>? attachments = do_list_attachments(cx, message_id,
cancellable);
if (attachments != null)
email.add_attachments(attachments);
}
Attachment.add_attachments(
cx, this.attachments_path, email, location.message_id, cancellable
);
return email;
}
private static string fields_to_columns(Geary.Email.Field fields) {
// always pull the rowid and fields of the message
StringBuilder builder = new StringBuilder("id, fields");
@ -2035,14 +2034,17 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics {
// Update attachments if not already in the database
if (!fetched_fields.fulfills(Attachment.REQUIRED_FIELDS)
&& combined_email.fields.fulfills(Attachment.REQUIRED_FIELDS)) {
do_save_attachments(cx, location.message_id, combined_email.get_message().get_attachments(),
cancellable);
combined_email.add_attachments(
Attachment.save_attachments(
cx,
this.attachments_path,
location.message_id,
combined_email.get_message().get_attachments(),
cancellable
)
);
}
// Must add attachments to the email object after they're saved to
// the database.
do_add_attachments(cx, combined_email, location.message_id, cancellable);
Geary.Email.Field new_fields;
do_merge_message_row(cx, row, out new_fields, out updated_contacts,
ref new_unread_count, cancellable);
@ -2061,201 +2063,7 @@ private class Geary.ImapDB.Folder : BaseObject, Geary.ReferenceSemantics {
unread_count_change += new_unread_count;
}
private static Gee.List<Geary.Attachment>? do_list_attachments(Db.Connection cx, int64 message_id,
Cancellable? cancellable) throws Error {
Db.Statement stmt = cx.prepare("""
SELECT id, filename, mime_type, filesize, disposition, content_id, description
FROM MessageAttachmentTable
WHERE message_id = ?
ORDER BY id
""");
stmt.bind_rowid(0, message_id);
Db.Result results = stmt.exec(cancellable);
if (results.finished)
return null;
Gee.List<Geary.Attachment> list = new Gee.ArrayList<Geary.Attachment>();
do {
string? content_filename = results.string_at(1);
if (content_filename == ImapDB.Attachment.NULL_FILE_NAME) {
// Prior to 0.12, Geary would store the untranslated
// string "none" as the filename when none was
// specified by the MIME content disposition. Check
// for that and clean it up.
content_filename = null;
}
Mime.ContentDisposition disposition = new Mime.ContentDisposition.simple(
Mime.DispositionType.from_int(results.int_at(4)));
list.add(
new ImapDB.Attachment(
message_id,
results.rowid_at(0),
Mime.ContentType.deserialize(results.nonnull_string_at(2)),
results.string_at(5),
results.string_at(6),
disposition,
content_filename,
cx.database.db_file.get_parent(),
results.int64_at(3)
)
);
} while (results.next(cancellable));
return list;
}
private void do_save_attachments(Db.Connection cx, int64 message_id,
Gee.List<GMime.Part>? attachments, Cancellable? cancellable) throws Error {
do_save_attachments_db(cx, message_id, attachments, db, cancellable);
}
public static void do_save_attachments_db(Db.Connection cx, int64 message_id,
Gee.List<GMime.Part>? attachments, ImapDB.Database db, Cancellable? cancellable) throws Error {
// nothing to do if no attachments
if (attachments == null || attachments.size == 0)
return;
foreach (GMime.Part attachment in attachments) {
GMime.ContentType? content_type = attachment.get_content_type();
string mime_type = (content_type != null)
? content_type.to_string()
: Mime.ContentType.DEFAULT_CONTENT_TYPE;
string? disposition = attachment.get_disposition();
string? content_id = attachment.get_content_id();
string? description = attachment.get_content_description();
string? filename = RFC822.Utils.get_clean_attachment_filename(attachment);
// Convert the attachment content into a usable ByteArray.
GMime.DataWrapper? attachment_data = attachment.get_content_object();
ByteArray byte_array = new ByteArray();
GMime.StreamMem stream = new GMime.StreamMem.with_byte_array(byte_array);
stream.set_owner(false);
if (attachment_data != null)
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, content_id, description)
VALUES (?, ?, ?, ?, ?, ?, ?)
""");
stmt.bind_rowid(0, message_id);
stmt.bind_string(1, filename);
stmt.bind_string(2, mime_type);
stmt.bind_uint(3, filesize);
stmt.bind_int(4, disposition_type);
stmt.bind_string(5, content_id);
stmt.bind_string(6, description);
int64 attachment_id = stmt.exec_insert(cancellable);
File saved_file = ImapDB.Attachment.generate_file(db.db_file.get_parent(), message_id,
attachment_id, filename);
// On the off-chance this is marked for deletion, unmark it
try {
stmt = cx.prepare("""
DELETE FROM DeleteAttachmentFileTable
WHERE filename = ?
""");
stmt.bind_string(0, saved_file.get_path());
stmt.exec(cancellable);
} catch (Error err) {
debug("Unable to delete from DeleteAttachmentFileTable: %s", err.message);
// not a deal-breaker, fall through
}
debug("Saving attachment to %s", saved_file.get_path());
try {
// create directory, but don't throw exception if already exists
try {
saved_file.get_parent().make_directory_with_parents(cancellable);
} catch (IOError ioe) {
// fall through if already exists
if (!(ioe is IOError.EXISTS))
throw ioe;
}
// REPLACE_DESTINATION doesn't seem to work as advertised all the time ... just
// play it safe here
if (saved_file.query_exists(cancellable))
saved_file.delete(cancellable);
// Create the file where the attachment will be saved and get the output stream.
FileOutputStream saved_stream = saved_file.create(FileCreateFlags.REPLACE_DESTINATION,
cancellable);
// Save the data to disk and flush it.
size_t written;
if (filesize != 0)
saved_stream.write_all(byte_array.data[0:filesize], out written, cancellable);
saved_stream.flush(cancellable);
} catch (Error error) {
// An error occurred while saving the attachment, so lets remove the attachment from
// the database and delete the file (in case it's partially written)
debug("Failed to save attachment %s: %s", saved_file.get_path(), error.message);
try {
saved_file.delete();
} catch (Error delete_error) {
debug("Error attempting to delete partial attachment %s: %s", saved_file.get_path(),
delete_error.message);
}
try {
Db.Statement remove_stmt = cx.prepare(
"DELETE FROM MessageAttachmentTable WHERE id=?");
remove_stmt.bind_rowid(0, attachment_id);
remove_stmt.exec();
} catch (Error remove_error) {
debug("Error attempting to remove added attachment row for %s: %s",
saved_file.get_path(), remove_error.message);
}
throw error;
}
}
}
public static void do_delete_attachments(Db.Connection cx, int64 message_id)
throws Error {
Gee.List<Geary.Attachment>? attachments = do_list_attachments(cx, message_id, null);
if (attachments == null || attachments.size == 0)
return;
// delete all files
foreach (Geary.Attachment attachment in attachments) {
try {
attachment.file.delete(null);
} catch (Error err) {
debug("Unable to delete file %s: %s", attachment.file.get_path(), err.message);
}
}
// remove all from attachment table
Db.Statement stmt = new Db.Statement(cx, """
DELETE FROM MessageAttachmentTable WHERE message_id = ?
""");
stmt.bind_rowid(0, message_id);
stmt.exec();
}
/**
* Adds a value to the unread count. If this makes the unread count negative, it will be
* set to zero.

View file

@ -83,12 +83,10 @@ private class Geary.ImapDB.GC {
private ImapDB.Database db;
private int priority;
private File data_dir;
public GC(ImapDB.Database db, int priority) {
this.db = db;
this.priority = priority;
data_dir = db.db_file.get_parent();
}
/**
@ -205,9 +203,10 @@ private class Geary.ImapDB.GC {
// NOTE: VACUUM cannot happen inside a transaction, so to avoid blocking the main thread,
// run a non-transacted command from a background thread
Geary.Db.Connection cx = yield db.open_connection(cancellable);
yield Nonblocking.Concurrent.global.schedule_async(() => {
db.open_connection(cancellable).exec("VACUUM", cancellable);
cx.exec("VACUUM", cancellable);
// it's a small thing, but take snapshot of time when vacuum completes, as scheduling
// of the next transaction is not instantaneous
last_vacuum_time = new DateTime.now_local();
@ -220,7 +219,7 @@ private class Geary.ImapDB.GC {
// update last vacuum time and reset messages reaped since last vacuum ... don't allow this
// to be cancelled, really want to get this in stone so the user doesn't re-vacuum
// unnecessarily
yield db.exec_transaction_async(Db.TransactionType.WO, (cx) => {
yield cx.exec_transaction_async(Db.TransactionType.WO, (cx) => {
Db.Statement stmt = cx.prepare("""
UPDATE GarbageCollectionTable
SET last_vacuum_time_t = ?, reaped_messages_since_last_vacuum = ?
@ -419,25 +418,11 @@ private class Geary.ImapDB.GC {
//
// Fetch all on-disk attachments for this message
//
Gee.ArrayList<File> attachment_files = new Gee.ArrayList<File>();
stmt = cx.prepare("""
SELECT id, filename
FROM MessageAttachmentTable
WHERE message_id = ?
""");
stmt.bind_rowid(0, message_id);
result = stmt.exec(cancellable);
while (!result.finished) {
File file = Attachment.generate_file(data_dir, message_id, result.rowid_for("id"),
result.string_for("filename"));
attachment_files.add(file);
result.next(cancellable);
}
Gee.List<Attachment> attachments = Attachment.list_attachments(
cx, this.db.attachments_path, message_id, cancellable
);
//
// Delete from search table
//
@ -480,17 +465,16 @@ private class Geary.ImapDB.GC {
// commits without error and the attachment files can be deleted without being
// referenced by the database, in a way that's resumable.
//
foreach (File attachment_file in attachment_files) {
foreach (Attachment attachment in attachments) {
stmt = cx.prepare("""
INSERT INTO DeleteAttachmentFileTable (filename)
VALUES (?)
""");
stmt.bind_string(0, attachment_file.get_path());
stmt.bind_string(0, attachment.file.get_path());
stmt.exec(cancellable);
}
//
// Increment the reap count since last vacuum
//
@ -571,8 +555,8 @@ private class Geary.ImapDB.GC {
private async int delete_empty_attachment_directories_async(File? current, out bool empty,
Cancellable? cancellable) throws Error {
File current_dir = current ?? Attachment.get_attachments_dir(db.db_file.get_parent());
File current_dir = current ?? db.attachments_path;
// directory is considered empty until file or non-deleted child directory is found
empty = true;
@ -662,9 +646,8 @@ private class Geary.ImapDB.GC {
reaped_messages_since_last_vacuum = reaped_count;
free_page_bytes = free_page_count * page_size;
}
public string to_string() {
return "GC:%s".printf(db.db_file.get_path());
return "GC:%s".printf(db.path);
}
}

View file

@ -996,8 +996,8 @@ private class Geary.Imap.FolderSession : Geary.Imap.SessionObject {
if (fetched_data.body_data_map.has_key(preview_specifier)
&& fetched_data.body_data_map.has_key(preview_charset_specifier)) {
email.set_message_preview(new RFC822.PreviewText.with_header(
fetched_data.body_data_map.get(preview_specifier),
fetched_data.body_data_map.get(preview_charset_specifier)));
fetched_data.body_data_map.get(preview_charset_specifier),
fetched_data.body_data_map.get(preview_specifier)));
} else {
message("[%s] No preview specifiers \"%s\" and \"%s\" found", folder_name,
preview_specifier.to_string(), preview_charset_specifier.to_string());

View file

@ -272,6 +272,7 @@ geary_engine_vala_sources = files(
'rfc822/rfc822-mailbox-address.vala',
'rfc822/rfc822-message.vala',
'rfc822/rfc822-message-data.vala',
'rfc822/rfc822-part.vala',
'rfc822/rfc822-utils.vala',
'smtp/smtp-authenticator.vala',
@ -296,7 +297,6 @@ geary_engine_vala_sources = files(
'util/util-ascii.vala',
'util/util-collection.vala',
'util/util-connectivity-manager.vala',
'util/util-converter.vala',
'util/util-files.vala',
'util/util-generic-capabilities.vala',
'util/util-html.vala',

View file

@ -6,8 +6,9 @@
/**
* Content parameters (for {@link ContentType} and {@link ContentDisposition}).
*
* This class is immutable.
*/
public class Geary.Mime.ContentParameters : BaseObject {
public int size {
get {
@ -37,14 +38,28 @@ public class Geary.Mime.ContentParameters : BaseObject {
if (params != null && params.size > 0)
Collection.map_set_all<string, string>(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();
/**
* Create a mapping of content parameters.
*
* Note that the given params must be a two-dimensional array,
* where each element contains a key/value pair.
*/
public ContentParameters.from_array(string[,] params) {
for (int i = 0; i < params.length[0]; i++) {
this.params.set(params[i,0], params[i,1]);
}
}
internal ContentParameters.from_gmime(GMime.Param? gmime_param) {
Gee.Map<string,string> params = new Gee.HashMap<string,string>();
while (gmime_param != null) {
params.set(gmime_param.get_name(), gmime_param.get_value());
gmime_param = gmime_param.get_next();
}
this(params);
}
/**
* A read-only mapping of parameter attributes (names) and values.
*
@ -89,26 +104,5 @@ public class Geary.Mime.ContentParameters : BaseObject {
return (stored != null) ? Ascii.str_equal(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);
}
}
}

View file

@ -8,8 +8,9 @@
* A representation of an RFC 2045 MIME Content-Type field.
*
* See [[https://tools.ietf.org/html/rfc2045#section-5]]
*
* This class is immutable.
*/
public class Geary.Mime.ContentType : Geary.BaseObject {
/**
@ -20,15 +21,32 @@ public class Geary.Mime.ContentType : Geary.BaseObject {
public const string WILDCARD = "*";
/**
* Default Content-Type for unknown or unmarked content.
* Default Content-Type for inline, displayed entities.
*
* This is as specified by RFC 2052 § 5.2.
*/
public const string DEFAULT_CONTENT_TYPE = "application/octet-stream";
public static ContentType DISPLAY_DEFAULT;
/**
* Default Content-Type for attached entities.
*
* Although RFC 2052 § 5.2 specifies US-ASCII as the default, for
* attachments assume a binary blob so that users aren't presented
* with garbled text editor content and warnings on opening it.
*/
public static ContentType ATTACHMENT_DEFAULT;
private static Gee.Map<string,string> TYPES_TO_EXTENSIONS =
new Gee.HashMap<string,string>();
static construct {
DISPLAY_DEFAULT = new ContentType(
"text", "plain",
new ContentParameters.from_array({{"charset", "us-ascii"}})
);
ATTACHMENT_DEFAULT = new ContentType("application", "octet-stream", null);
// XXX We should be loading file name extension information
// from /etc/mime.types and/or the XDG Shared MIME-info
// Database globs2 file, usually located at
@ -61,8 +79,11 @@ public class Geary.Mime.ContentType : Geary.BaseObject {
/**
* Attempts to guess the content type for a buffer using GIO sniffing.
*
* Returns null if it could not be guessed.
*/
public static ContentType guess_type(string? file_name, Geary.Memory.Buffer? buf) throws Error {
public static ContentType? guess_type(string? file_name, Geary.Memory.Buffer? buf)
throws Error {
string? mime_type = null;
if (file_name != null) {
@ -89,10 +110,7 @@ public class Geary.Mime.ContentType : Geary.BaseObject {
mime_type = GLib.ContentType.get_mime_type(glib_type);
}
if (Geary.String.is_empty(mime_type)) {
mime_type = DEFAULT_CONTENT_TYPE;
}
return deserialize(mime_type);
return !Geary.String.is_empty(mime_type) ? deserialize(mime_type) : null;
}
@ -234,13 +252,6 @@ public class Geary.Mime.ContentType : Geary.BaseObject {
return is_type(mime_media_type, mime_media_subtype);
}
/**
* Determines if this type is the same as the default content type.
*/
public bool is_default() {
return get_mime_type() == DEFAULT_CONTENT_TYPE;
}
public string serialize() {
StringBuilder builder = new StringBuilder();
builder.append_printf("%s/%s", media_type, media_subtype);

View file

@ -373,49 +373,46 @@ public class Geary.RFC822.PreviewText : Geary.RFC822.Text {
base (_buffer);
}
public PreviewText.with_header(Memory.Buffer preview, Memory.Buffer preview_header) {
string? charset = null;
string? encoding = null;
bool is_plain = false;
bool is_html = false;
public PreviewText.with_header(Memory.Buffer preview_header, Memory.Buffer preview) {
string preview_text = "";
// Parse the header.
GMime.Stream header_stream = Utils.create_stream_mem(preview_header);
GMime.Parser parser = new GMime.Parser.with_stream(header_stream);
GMime.Part? part = parser.construct_part() as GMime.Part;
if (part != null) {
Mime.ContentType? content_type = null;
if (part.get_content_type() != null) {
content_type = new Mime.ContentType.from_gmime(part.get_content_type());
is_plain = content_type.is_type("text", "plain");
is_html = content_type.is_type("text", "html");
charset = content_type.params.get_value("charset");
GMime.Part? gpart = parser.construct_part() as GMime.Part;
if (gpart != null) {
Part part = new Part(gpart);
Mime.ContentType content_type = part.get_effective_content_type();
bool is_plain = content_type.is_type("text", "plain");
bool is_html = content_type.is_type("text", "html");
if (is_plain || is_html) {
// Parse the partial body
GMime.DataWrapper body = new GMime.DataWrapper.with_stream(
new GMime.StreamMem.with_buffer(preview.get_uint8_array()),
gpart.get_content_encoding()
);
gpart.set_content_object(body);
ByteArray output = new ByteArray();
GMime.StreamMem output_stream =
new GMime.StreamMem.with_byte_array(output);
output_stream.set_owner(false);
try {
part.write_to_stream(output_stream);
uint8[] data = output.data;
data += (uint8) '\0';
preview_text = Geary.RFC822.Utils.to_preview_text(
(string) data,
is_html ? TextFormat.HTML : TextFormat.PLAIN
);
} catch (RFC822Error err) {
debug("Failed to parse preview body: %s", err.message);
}
}
encoding = part.get_header("Content-Transfer-Encoding");
}
string preview_text = "";
if (is_plain || is_html) {
// Parse the preview
GMime.StreamMem input_stream = Utils.create_stream_mem(preview);
ByteArray output = new ByteArray();
GMime.StreamMem output_stream = new GMime.StreamMem.with_byte_array(output);
output_stream.set_owner(false);
// Convert the encoding and character set.
GMime.StreamFilter filter = new GMime.StreamFilter(output_stream);
if (encoding != null)
filter.add(new GMime.FilterBasic(GMime.content_encoding_from_string(encoding), false));
filter.add(Geary.RFC822.Utils.create_utf8_filter_charset(charset));
filter.add(new GMime.FilterCRLF(false, false));
input_stream.write_to_stream(filter);
uint8[] data = output.data;
data += (uint8) '\0';
preview_text = Geary.RFC822.Utils.to_preview_text((string) data, is_html ? TextFormat.HTML : TextFormat.PLAIN);
}
base(new Geary.Memory.StringBuffer(preview_text));

View file

@ -16,15 +16,18 @@
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.
* Callback for including non-text MIME entities in message bodies.
*
* 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.
* 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(string? filename, Mime.ContentType? content_type,
Mime.ContentDisposition? disposition, string? content_id, Geary.Memory.Buffer buffer);
public delegate string? InlinePartReplacer(Part part);
private const string HEADER_SENDER = "Sender";
private const string HEADER_IN_REPLY_TO = "In-Reply-To";
@ -471,7 +474,7 @@ public class Geary.RFC822.Message : BaseObject {
* Determines if the message has one or plain text display parts.
*/
public bool has_plain_body() {
return has_body_parts(message.get_mime_part(), "text");
return has_body_parts(message.get_mime_part(), "plain");
}
/**
@ -485,38 +488,29 @@ public class Geary.RFC822.Message : BaseObject {
* construct_body_from_mime_parts.
*/
private bool has_body_parts(GMime.Object node, string text_subtype) {
bool has_part = false;
Mime.ContentType? this_content_type = null;
if (node.get_content_type() != null)
this_content_type =
new Mime.ContentType.from_gmime(node.get_content_type());
Part part = new Part(node);
bool is_matching_part = false;
GMime.Multipart? multipart = node as GMime.Multipart;
if (multipart != null) {
if (node is GMime.Multipart) {
GMime.Multipart multipart = (GMime.Multipart) node;
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);
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;
}
} 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 != null &&
this_content_type.has_media_type("text") &&
this_content_type.has_media_subtype(text_subtype)) {
has_part = true;
}
}
}
is_matching_part = (
disposition != Mime.DispositionType.ATTACHMENT &&
part.get_effective_content_type().is_type("text", text_subtype)
);
}
return has_part;
return is_matching_part;
}
/**
@ -535,18 +529,22 @@ public class Geary.RFC822.Message : BaseObject {
*
* @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 {
Mime.ContentType? this_content_type = null;
if (node.get_content_type() != null)
this_content_type = new Mime.ContentType.from_gmime(node.get_content_type());
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 {
Part part = new Part(node);
Mime.ContentType content_type = part.get_effective_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);
Mime.MultipartSubtype this_subtype =
Mime.MultipartSubtype.from_content_type(content_type, null);
bool found_text_subtype = false;
StringBuilder builder = new StringBuilder();
@ -566,45 +564,33 @@ public class Geary.RFC822.Message : BaseObject {
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;
Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED;
if (part.content_disposition != null) {
disposition = part.content_disposition.disposition_type;
}
// 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));
// 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(
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;
@ -741,47 +727,10 @@ public class Geary.RFC822.Message : BaseObject {
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(
internal Gee.List<Part> get_attachments(
Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED) throws RFC822Error {
Gee.List<GMime.Part> attachments = new Gee.ArrayList<GMime.Part>();
Gee.List<Part> attachments = new Gee.LinkedList<Part>();
get_attachments_recursively(attachments, message.get_mime_part(), disposition);
return attachments;
}
@ -865,21 +814,19 @@ public class Geary.RFC822.Message : BaseObject {
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) {
private void get_attachments_recursively(Gee.List<Part> attachments,
GMime.Object root,
Mime.DispositionType requested_disposition)
throws RFC822Error {
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);
}
return;
}
// If this is an attached message, go through it.
GMime.MessagePart? messagepart = root as GMime.MessagePart;
if (messagepart != null) {
} 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(),
@ -897,40 +844,37 @@ public class Geary.RFC822.Message : BaseObject {
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);
attachments.add(new Part(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;
} 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.get_effective_content_type();
// Skip text/plain and text/html parts that are INLINE
// or UNSPECIFIED, as they will be included in the body
if (actual_disposition == Mime.DispositionType.ATTACHMENT ||
(!content_type.is_type("text", "plain") &&
!content_type.is_type("text", "html"))) {
attachments.add(part);
}
}
}
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());
@ -975,57 +919,6 @@ public class Geary.RFC822.Message : BaseObject {
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();

View file

@ -0,0 +1,195 @@
/*
* 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-2045 style MIME entity.
*
* This object provides a convenient means accessing the high-level
* MIME entity header field values that are useful to applications and
* decoded forms of the entity body.
*/
public class Geary.RFC822.Part : Object {
/** Specifies a format to apply to body data when writing it. */
public enum BodyFormatting {
/** No formatting will be applied. */
NONE,
/** Plain text bodies will be formatted as HTML. */
HTML;
}
/**
* The entity's Content-Type.
*
* See [[https://tools.ietf.org/html/rfc2045#section-5]]
*/
public Mime.ContentType? content_type { get; private set; }
/**
* The entity's Content-ID.
*
* See [[https://tools.ietf.org/html/rfc2045#section-5]],
* [[https://tools.ietf.org/html/rfc2111]] and {@link
* Email.get_attachment_by_content_id}.
*/
public string? content_id { get; private set; }
/**
* The entity's Content-Description.
*
* See [[https://tools.ietf.org/html/rfc2045#section-8]]
*/
public string? content_description { get; private set; }
/**
* The entity's Content-Disposition.
*
* See [[https://tools.ietf.org/html/rfc2183]]
*/
public Mime.ContentDisposition? content_disposition { get; private set; }
private GMime.Object source_object;
private GMime.Part? source_part;
internal Part(GMime.Object source) {
this.source_object = source;
this.source_part = source as GMime.Part;
GMime.ContentType? part_type = source.get_content_type();
if (part_type != null) {
this.content_type = new Mime.ContentType.from_gmime(part_type);
}
this.content_id = source.get_content_id();
this.content_description = (this.source_part != null)
? source_part.get_content_description() : null;
GMime.ContentDisposition? part_disposition = source.get_content_disposition();
if (part_disposition != null) {
this.content_disposition = new Mime.ContentDisposition.from_gmime(
part_disposition
);
}
}
/**
* The entity's effective Content-Type.
*
* This returns the entity's content type if set, else returns
* {@link Geary.Mime.ContentType.DISPLAY_DEFAULT} this is a
* displayable (i.e. non-attachment) entity, or {@link
* Geary.Mime.ContentType.}
*/
public Mime.ContentType get_effective_content_type() {
Mime.ContentType? type = this.content_type;
if (type == null) {
Mime.DispositionType disposition = Mime.DispositionType.UNSPECIFIED;
if (this.content_disposition != null) {
disposition = this.content_disposition.disposition_type;
}
type = (disposition != Mime.DispositionType.ATTACHMENT)
? Mime.ContentType.DISPLAY_DEFAULT
: Mime.ContentType.ATTACHMENT_DEFAULT;
}
return type;
}
/**
* Returns the entity's filename, cleaned for use in the file system.
*/
public string? get_clean_filename() {
string? filename = (this.source_part != null)
? this.source_part.get_filename() : null;
if (filename != null) {
try {
filename = invalid_filename_character_re.replace_literal(
filename, filename.length, 0, "_"
);
} catch (RegexError e) {
debug("Error sanitizing attachment filename: %s", e.message);
}
}
return filename;
}
public Memory.Buffer write_to_buffer(BodyFormatting format = BodyFormatting.NONE)
throws RFC822Error {
ByteArray byte_array = new ByteArray();
GMime.StreamMem stream = new GMime.StreamMem.with_byte_array(byte_array);
stream.set_owner(false);
write_to_stream(stream, format);
return new Geary.Memory.ByteBuffer.from_byte_array(byte_array);
}
internal void write_to_stream(GMime.Stream destination,
BodyFormatting format = BodyFormatting.NONE)
throws RFC822Error {
GMime.DataWrapper? wrapper = (this.source_part != null)
? this.source_part.get_content_object() : null;
if (wrapper == null) {
throw new RFC822Error.INVALID(
"Could not get the content wrapper for content-type %s",
content_type.to_string()
);
}
Mime.ContentType content_type = this.get_effective_content_type();
if (content_type.is_type("text", Mime.ContentType.WILDCARD)) {
// Assume encoded text, convert to unencoded UTF-8
GMime.StreamFilter filter = new GMime.StreamFilter(destination);
string? charset = content_type.params.get_value("charset");
filter.add(
Geary.RFC822.Utils.create_utf8_filter_charset(charset)
);
bool flowed = content_type.params.has_value_ci("format", "flowed");
bool delsp = content_type.params.has_value_ci("DelSp", "yes");
// Unconditionally remove the CR's in any CRLF sequence, since
// they are effectively a wire encoding.
filter.add(new GMime.FilterCRLF(false, false));
if (flowed) {
filter.add(
new Geary.RFC822.FilterFlowed(
format == BodyFormatting.HTML, delsp
)
);
}
if (format == BodyFormatting.HTML) {
if (!flowed) {
filter.add(new Geary.RFC822.FilterPlain());
}
filter.add(
new GMime.FilterHTML(
GMime.FILTER_HTML_CONVERT_URLS |
GMime.FILTER_HTML_CONVERT_ADDRESSES,
0
)
);
filter.add(new Geary.RFC822.FilterBlockquotes());
}
wrapper.write_to_stream(filter);
filter.flush();
} else {
// Keep as binary
wrapper.write_to_stream(destination);
destination.flush();
}
}
}

View file

@ -433,19 +433,5 @@ public GMime.ContentEncoding get_best_encoding(GMime.Stream in_stream) {
return filter.encoding(GMime.EncodingConstraint.7BIT);
}
public string? get_clean_attachment_filename(GMime.Part part) {
string? filename = part.get_filename();
if (filename != null) {
try {
filename = invalid_filename_character_re.replace_literal(
filename, filename.length, 0, "_"
);
} catch (RegexError e) {
debug("Error sanitizing attachment filename: %s", e.message);
}
}
return filename;
}
}

View file

@ -1,77 +0,0 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class Geary.Stream.MidstreamConverter : BaseObject, Converter {
public uint64 total_bytes_read { get; private set; default = 0; }
public uint64 total_bytes_written { get; private set; default = 0; }
public uint64 converted_bytes_read { get; private set; default = 0; }
public uint64 converted_bytes_written { get; private set; default = 0; }
public bool log_performance { get; set; default = false; }
private string name;
private Converter? converter = null;
public MidstreamConverter(string name) {
this.name = name;
}
public bool install(Converter converter) {
if (this.converter != null)
return false;
this.converter = converter;
return true;
}
public ConverterResult convert(uint8[] inbuf, uint8[] outbuf, ConverterFlags flags,
out size_t bytes_read, out size_t bytes_written) throws Error {
if (converter != null) {
ConverterResult result = converter.convert(inbuf, outbuf, flags, out bytes_read, out bytes_written);
total_bytes_read += bytes_read;
total_bytes_written += bytes_written;
converted_bytes_read += bytes_read;
converted_bytes_written += bytes_written;
if (log_performance && (bytes_read > 0 || bytes_written > 0)) {
double pct = (converted_bytes_read > converted_bytes_written)
? (double) converted_bytes_written / (double) converted_bytes_read
: (double) converted_bytes_read / (double) converted_bytes_written;
debug("%s read/written: %s/%s (%ld%%)", name, converted_bytes_read.to_string(),
converted_bytes_written.to_string(), (long) (pct * 100.0));
}
return result;
}
// passthrough
size_t copied = size_t.min(inbuf.length, outbuf.length);
if (copied > 0)
GLib.Memory.copy(outbuf, inbuf, copied);
bytes_read = copied;
bytes_written = copied;
total_bytes_read += copied;
total_bytes_written += copied;
if ((flags & ConverterFlags.FLUSH) != 0)
return ConverterResult.FLUSHED;
if ((flags & ConverterFlags.INPUT_AT_END) != 0)
return ConverterResult.FINISHED;
return ConverterResult.CONVERTED;
}
public void reset() {
if (converter != null)
converter.reset();
}
}

View file

@ -6,40 +6,169 @@
namespace Geary.Stream {
/**
* Provides an asynchronous version of OutputStream.write_all().
*/
public async void write_all_async(OutputStream outs, Memory.Buffer buffer, Cancellable? cancellable)
/**
* Provides an asynchronous version of OutputStream.write_all().
*/
public async void write_all_async(OutputStream outs, Memory.Buffer buffer, Cancellable? cancellable)
throws Error {
if (buffer.size == 0)
return;
// use an unowned bytes buffer whenever possible
Bytes? bytes = null;
unowned uint8[] data;
Memory.UnownedBytesBuffer? unowned_bytes = buffer as Memory.UnownedBytesBuffer;
if (unowned_bytes != null) {
data = unowned_bytes.to_unowned_uint8_array();
} else {
// hold the reference to the Bytes object until finished
bytes = buffer.get_bytes();
data = bytes.get_data();
if (buffer.size == 0)
return;
// use an unowned bytes buffer whenever possible
Bytes? bytes = null;
unowned uint8[] data;
Memory.UnownedBytesBuffer? unowned_bytes = buffer as Memory.UnownedBytesBuffer;
if (unowned_bytes != null) {
data = unowned_bytes.to_unowned_uint8_array();
} else {
// hold the reference to the Bytes object until finished
bytes = buffer.get_bytes();
data = bytes.get_data();
}
ssize_t offset = 0;
do {
offset += yield outs.write_async(data[offset:data.length], Priority.DEFAULT, cancellable);
} while (offset < data.length);
}
ssize_t offset = 0;
do {
offset += yield outs.write_async(data[offset:data.length], Priority.DEFAULT, cancellable);
} while (offset < data.length);
}
/**
* Asynchronously writes the entire string to the OutputStream.
*/
public async void write_string_async(OutputStream outs, string? str, Cancellable? cancellable)
/**
* Asynchronously writes the entire string to the OutputStream.
*/
public async void write_string_async(OutputStream outs, string? str, Cancellable? cancellable)
throws Error {
if (!String.is_empty(str))
yield write_all_async(outs, new Memory.StringBuffer(str), cancellable);
}
if (!String.is_empty(str))
yield write_all_async(outs, new Memory.StringBuffer(str), cancellable);
}
public class MidstreamConverter : BaseObject, Converter {
public uint64 total_bytes_read { get; private set; default = 0; }
public uint64 total_bytes_written { get; private set; default = 0; }
public uint64 converted_bytes_read { get; private set; default = 0; }
public uint64 converted_bytes_written { get; private set; default = 0; }
public bool log_performance { get; set; default = false; }
private string name;
private Converter? converter = null;
public MidstreamConverter(string name) {
this.name = name;
}
public bool install(Converter converter) {
if (this.converter != null)
return false;
this.converter = converter;
return true;
}
public ConverterResult convert(uint8[] inbuf, uint8[] outbuf, ConverterFlags flags,
out size_t bytes_read, out size_t bytes_written) throws Error {
if (converter != null) {
ConverterResult result = converter.convert(inbuf, outbuf, flags, out bytes_read, out bytes_written);
total_bytes_read += bytes_read;
total_bytes_written += bytes_written;
converted_bytes_read += bytes_read;
converted_bytes_written += bytes_written;
if (log_performance && (bytes_read > 0 || bytes_written > 0)) {
double pct = (converted_bytes_read > converted_bytes_written)
? (double) converted_bytes_written / (double) converted_bytes_read
: (double) converted_bytes_read / (double) converted_bytes_written;
debug("%s read/written: %s/%s (%ld%%)", name, converted_bytes_read.to_string(),
converted_bytes_written.to_string(), (long) (pct * 100.0));
}
return result;
}
// passthrough
size_t copied = size_t.min(inbuf.length, outbuf.length);
if (copied > 0)
GLib.Memory.copy(outbuf, inbuf, copied);
bytes_read = copied;
bytes_written = copied;
total_bytes_read += copied;
total_bytes_written += copied;
if ((flags & ConverterFlags.FLUSH) != 0)
return ConverterResult.FLUSHED;
if ((flags & ConverterFlags.INPUT_AT_END) != 0)
return ConverterResult.FINISHED;
return ConverterResult.CONVERTED;
}
public void reset() {
if (converter != null)
converter.reset();
}
}
/**
* Adaptor from a GMime stream to a GLib OutputStream.
*/
public class MimeOutputStream : GMime.Stream {
GLib.OutputStream dest;
int64 written = 0;
public MimeOutputStream(GLib.OutputStream dest) {
this.dest = dest;
}
public override int64 length() {
// This is a bit of a kludge, but we use it in
// ImapDB.Attachment
return this.written;
}
public override ssize_t write(string buf, size_t len) {
ssize_t ret = -1;
try {
ret = this.dest.write(buf.data[0:len]);
this.written += len;
} catch (IOError err) {
// Oh well
}
return ret;
}
public override int close() {
int ret = -1;
try {
ret = this.dest.close() ? 0 : -1;
} catch (IOError err) {
// Oh well
}
return ret;
}
public override int flush () {
int ret = -1;
try {
ret = this.dest.flush() ? 0 : -1;
} catch (Error err) {
// Oh well
}
return ret;
}
public override bool eos () {
return this.dest.is_closed() || this.dest.is_closing();
}
}
}

View file

@ -26,15 +26,20 @@ set(TEST_ENGINE_SRC
engine/app/app-conversation-test.vala
engine/app/app-conversation-monitor-test.vala
engine/app/app-conversation-set-test.vala
engine/db/db-database-test.vala
engine/db/db-versioned-database-test.vala
engine/imap/command/imap-create-command-test.vala
engine/imap/response/imap-namespace-response-test.vala
engine/imap/transport/imap-deserializer-test.vala
engine/imap-db/imap-db-attachment-test.vala
engine/imap-db/imap-db-database-test.vala
engine/imap-engine/account-processor-test.vala
engine/mime-content-type-test.vala
engine/rfc822-mailbox-address-test.vala
engine/rfc822-mailbox-addresses-test.vala
engine/rfc822-message-test.vala
engine/rfc822-message-data-test.vala
engine/rfc822-part-test.vala
engine/rfc822-utils-test.vala
engine/util-ascii-test.vala
engine/util-html-test.vala

View file

@ -0,0 +1,36 @@
From: Alice <alice@example.net>
Sender: Bob <bob@example.net>
To: Charlie <charlie@example.net>
CC: Dave <dave@example.net>
BCC: Eve <eve@example.net>
Reply-To: \"Alice: Personal Account\" <alice@example.org>
Subject: Re: Basic text/html message
Date: Fri, 21 Nov 1997 10:01:10 -0600
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="=-NJextDaQ1tE2ZGhW9Wm0"
Message-ID: <3456@example.net>
In-Reply-To: <1234@local.machine.example>
References: <1234@local.machine.example>
X-Mailer: Geary Test Suite 1.0
--=-NJextDaQ1tE2ZGhW9Wm0
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: quoted-printable
This is the first line.
This is the second line.
=
--=-NJextDaQ1tE2ZGhW9Wm0
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<P>This is the first line.
<P>This is the second line.
=
--=-NJextDaQ1tE2ZGhW9Wm0--

View file

@ -0,0 +1,18 @@
From: Alice <alice@example.net>
Sender: Bob <bob@example.net>
To: Charlie <charlie@example.net>
CC: Dave <dave@example.net>
BCC: Eve <eve@example.net>
Reply-To: \"Alice: Personal Account\" <alice@example.org>
Subject: Re: Basic text/html message
Date: Fri, 21 Nov 1997 10:01:10 -0600
Content-Type: text/html; charset=UTF-8
Message-ID: <3456@example.net>
In-Reply-To: <1234@local.machine.example>
References: <1234@local.machine.example>
X-Mailer: Geary Test Suite 1.0
<P>This is the first line.
<P>This is the second line.

View file

@ -0,0 +1,17 @@
From: Alice <alice@example.net>
Sender: Bob <bob@example.net>
To: Charlie <charlie@example.net>
CC: Dave <dave@example.net>
BCC: Eve <eve@example.net>
Reply-To: "Alice: Personal Account" <alice@example.org>
Subject: Re: Basic text/plain message
Date: Fri, 21 Nov 1997 10:01:10 -0600
Message-ID: <3456@example.net>
In-Reply-To: <1234@local.machine.example>
References: <1234@local.machine.example>
X-Mailer: Geary Test Suite 1.0
This is the first line.
This is the second line.

Binary file not shown.

3
test/data/meson.build Normal file
View file

@ -0,0 +1,3 @@
geary_test_engine_resources = gnome.compile_resources('org.gnome.GearyTest',
files('org.gnome.GearyTest.gresource.xml'),
)

View file

@ -0,0 +1,9 @@
<?xml version='1.0' encoding='UTF-8'?>
<gresources>
<gresource prefix="/org/gnome/GearyTest">
<file>basic-text-plain.eml</file>
<file>basic-text-html.eml</file>
<file>basic-multipart-alternative.eml</file>
<file>geary-0.6-db.tar.xz</file>
</gresource>
</gresources>

View file

@ -10,7 +10,6 @@ extern const string _SOURCE_ROOT_DIR;
class Geary.AttachmentTest : TestCase {
private const string ATTACHMENT_ID = "test-id";
private const string CONTENT_TYPE = "image/png";
private const string CONTENT_ID = "test-content-id";
private const string CONTENT_DESC = "Mea navis volitans anguillis plena est";
@ -21,19 +20,19 @@ class Geary.AttachmentTest : TestCase {
private Mime.ContentDisposition? content_disposition;
private File? file;
private class TestAttachment : Attachment {
// A test article
internal TestAttachment(string id,
Mime.ContentType content_type,
internal TestAttachment(Mime.ContentType content_type,
string? content_id,
string? content_description,
Mime.ContentDisposition content_disposition,
string? content_filename,
File file,
int64 filesize) {
base(id, content_type, content_id, content_description,
content_disposition, content_filename, file, filesize);
GLib.File file) {
base(content_type, content_id, content_description,
content_disposition, content_filename);
set_file_info(file, 742);
}
}
@ -63,7 +62,7 @@ class Geary.AttachmentTest : TestCase {
public override void set_up() {
try {
this.content_type = Mime.ContentType.deserialize(CONTENT_TYPE);
this.default_type = Mime.ContentType.deserialize(Mime.ContentType.DEFAULT_CONTENT_TYPE);
this.default_type = Mime.ContentType.ATTACHMENT_DEFAULT;
this.content_disposition = new Mime.ContentDisposition("attachment", null);
File source = File.new_for_path(_SOURCE_ROOT_DIR);
@ -76,14 +75,12 @@ class Geary.AttachmentTest : TestCase {
public void get_safe_file_name_with_content_name() throws Error {
const string TEST_FILENAME = "test-filename.png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.content_type,
CONTENT_ID,
CONTENT_DESC,
content_disposition,
TEST_FILENAME,
this.file,
742
this.file
);
test.get_safe_file_name.begin(null, (obj, ret) => {
@ -97,14 +94,12 @@ class Geary.AttachmentTest : TestCase {
const string TEST_FILENAME = "test-filename.jpg";
const string RESULT_FILENAME = "test-filename.jpg.png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.content_type,
CONTENT_ID,
CONTENT_DESC,
content_disposition,
TEST_FILENAME,
this.file,
742
this.file
);
test.get_safe_file_name.begin(null, (obj, ret) => {
@ -118,14 +113,12 @@ class Geary.AttachmentTest : TestCase {
const string TEST_FILENAME = "test-filename";
const string RESULT_FILENAME = "test-filename.png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.content_type,
CONTENT_ID,
CONTENT_DESC,
content_disposition,
TEST_FILENAME,
this.file,
742
this.file
);
test.get_safe_file_name.begin(null, (obj, ret) => {
@ -138,14 +131,12 @@ class Geary.AttachmentTest : TestCase {
public void get_safe_file_name_with_no_content_name() throws Error {
const string RESULT_FILENAME = CONTENT_ID + ".png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.content_type,
CONTENT_ID,
CONTENT_DESC,
content_disposition,
null,
this.file,
742
this.file
);
test.get_safe_file_name.begin(null, (obj, ret) => {
@ -156,16 +147,14 @@ class Geary.AttachmentTest : TestCase {
}
public void get_safe_file_name_with_no_content_name_or_id() throws Error {
const string RESULT_FILENAME = ATTACHMENT_ID + ".png";
const string RESULT_FILENAME = "attachment.png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.content_type,
null,
CONTENT_DESC,
content_disposition,
null,
this.file,
742
this.file
);
test.get_safe_file_name.begin(null, (obj, ret) => {
@ -179,14 +168,12 @@ class Geary.AttachmentTest : TestCase {
const string ALT_TEXT = "some text";
const string RESULT_FILENAME = "some text.png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.content_type,
null,
CONTENT_DESC,
content_disposition,
null,
this.file,
742
this.file
);
test.get_safe_file_name.begin(ALT_TEXT, (obj, ret) => {
@ -199,14 +186,12 @@ class Geary.AttachmentTest : TestCase {
public void get_safe_file_name_with_default_content_type() throws Error {
const string TEST_FILENAME = "test-filename.png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.default_type,
CONTENT_ID,
CONTENT_DESC,
content_disposition,
TEST_FILENAME,
this.file,
742
this.file
);
test.get_safe_file_name.begin(null, (obj, ret) => {
@ -221,14 +206,12 @@ class Geary.AttachmentTest : TestCase {
const string TEST_FILENAME = "test-filename.jpg";
const string RESULT_FILENAME = "test-filename.jpg.png";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.default_type,
CONTENT_ID,
CONTENT_DESC,
content_disposition,
TEST_FILENAME,
this.file,
742
this.file
);
test.get_safe_file_name.begin(null, (obj, ret) => {
@ -242,14 +225,12 @@ class Geary.AttachmentTest : TestCase {
throws Error {
const string TEST_FILENAME = "test-filename.unlikely";
Attachment test = new TestAttachment(
ATTACHMENT_ID,
this.default_type,
CONTENT_ID,
CONTENT_DESC,
content_disposition,
TEST_FILENAME,
File.new_for_path(TEST_FILENAME),
742
File.new_for_path(TEST_FILENAME)
);
test.get_safe_file_name.begin(null, (obj, ret) => {

View file

@ -0,0 +1,125 @@
/*
* 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.
*/
class Geary.Db.DatabaseTest : TestCase {
public DatabaseTest() {
base("Geary.Db.DatabaseTest");
add_test("transient_open", transient_open);
add_test("open_existing", open_existing);
add_test("open_create_file", open_create_file);
add_test("open_create_dir", open_create_dir);
add_test("open_create_dir_existing", open_create_dir_existing);
}
public void transient_open() throws Error {
Database db = new Geary.Db.Database.transient();
db.open.begin(
Geary.Db.DatabaseFlags.NONE, null, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
// Need to get a connection since the database doesn't
// actually get created until then
db.get_master_connection();
}
public void open_existing() throws Error {
GLib.FileIOStream stream;
GLib.File tmp_file = GLib.File.new_tmp(
"geary-db-database-test-XXXXXX", out stream
);
Database db = new Geary.Db.Database.persistent(tmp_file);
db.open.begin(
Geary.Db.DatabaseFlags.NONE, null, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
// Need to get a connection since the database doesn't
// actually get created until then
db.get_master_connection();
tmp_file.delete();
}
public void open_create_file() throws Error {
GLib.File tmp_dir = GLib.File.new_for_path(
GLib.DirUtils.make_tmp("geary-db-database-test-XXXXXX")
);
Database db = new Geary.Db.Database.persistent(
tmp_dir.get_child("test.db")
);
db.open.begin(
Geary.Db.DatabaseFlags.CREATE_FILE, null, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
// Need to get a connection since the database doesn't
// actually get created until then
db.get_master_connection();
db.file.delete();
tmp_dir.delete();
}
public void open_create_dir() throws Error {
GLib.File tmp_dir = GLib.File.new_for_path(
GLib.DirUtils.make_tmp("geary-db-database-test-XXXXXX")
);
Database db = new Geary.Db.Database.persistent(
tmp_dir.get_child("nonexistent").get_child("test.db")
);
db.open.begin(
Geary.Db.DatabaseFlags.CREATE_DIRECTORY |
Geary.Db.DatabaseFlags.CREATE_FILE,
null, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
// Need to get a connection since the database doesn't
// actually get created until then
db.get_master_connection();
db.file.delete();
db.file.get_parent().delete();
tmp_dir.delete();
}
public void open_create_dir_existing() throws Error {
GLib.File tmp_dir = GLib.File.new_for_path(
GLib.DirUtils.make_tmp("geary-db-database-test-XXXXXX")
);
Database db = new Geary.Db.Database.persistent(
tmp_dir.get_child("test.db")
);
db.open.begin(
Geary.Db.DatabaseFlags.CREATE_DIRECTORY |
Geary.Db.DatabaseFlags.CREATE_FILE,
null, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
// Need to get a connection since the database doesn't
// actually get created until then
db.get_master_connection();
db.file.delete();
tmp_dir.delete();
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.
*/
class Geary.Db.VersionedDatabaseTest : TestCase {
public VersionedDatabaseTest() {
base("Geary.Db.VersionedDatabaseTest");
add_test("open_new", open_new);
}
public void open_new() throws Error {
GLib.File tmp_dir = GLib.File.new_for_path(
GLib.DirUtils.make_tmp("geary-db-database-test-XXXXXX")
);
GLib.File sql1 = tmp_dir.get_child("version-001.sql");
sql1.create(
GLib.FileCreateFlags.NONE
).write("CREATE TABLE TestTable (id INTEGER PRIMARY KEY, col TEXT);".data);
GLib.File sql2 = tmp_dir.get_child("version-002.sql");
sql2.create(
GLib.FileCreateFlags.NONE
).write("INSERT INTO TestTable (col) VALUES ('value');".data);
VersionedDatabase db = new VersionedDatabase.persistent(
tmp_dir.get_child("test.db"), tmp_dir
);
db.open.begin(
Geary.Db.DatabaseFlags.CREATE_FILE, null, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
Geary.Db.Result result = db.query("SELECT * FROM TestTable;");
assert_false(result.finished, "Row not inserted");
assert_string("value", result.string_for("col"));
assert_false(result.next(), "Multiple rows inserted");
db.file.delete();
sql1.delete();
sql2.delete();
tmp_dir.delete();
}
}

View file

@ -0,0 +1,364 @@
/*
* 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.
*/
class Geary.ImapDB.AttachmentTest : TestCase {
private const string ATTACHMENT_BODY = "This is an attachment.\r\n";
public AttachmentTest() {
base("Geary.ImapDB.AttachmentTest");
add_test("new_from_minimal_mime_part", new_from_minimal_mime_part);
add_test("new_from_complete_mime_part", new_from_complete_mime_part);
add_test("new_from_inline_mime_part", new_from_inline_mime_part);
}
public void new_from_minimal_mime_part() throws Error {
GMime.Part part = new_part(null, ATTACHMENT_BODY.data);
part.set_header("Content-Type", "");
Attachment test = new Attachment.from_part(
1, new Geary.RFC822.Part(part)
);
assert_string(
Geary.Mime.ContentType.ATTACHMENT_DEFAULT.to_string(),
test.content_type.to_string()
);
assert_null_string(test.content_id, "content_id");
assert_null_string(test.content_description, "content_description");
assert_int(
Geary.Mime.DispositionType.UNSPECIFIED,
test.content_disposition.disposition_type,
"content disposition type"
);
assert_false(test.has_content_filename, "has_content_filename");
assert_null_string(test.content_filename, "content_filename");
}
public void new_from_complete_mime_part() throws Error {
const string TYPE = "text/plain";
const string ID = "test-id";
const string DESC = "test description";
const string NAME = "test.txt";
GMime.Part part = new_part(null, ATTACHMENT_BODY.data);
part.set_content_id(ID);
part.set_content_description(DESC);
part.set_content_disposition(
new GMime.ContentDisposition.from_string(
"attachment; filename=%s".printf(NAME)
)
);
Attachment test = new Attachment.from_part(
1, new Geary.RFC822.Part(part)
);
assert_string(TYPE, test.content_type.to_string());
assert_string(ID, test.content_id);
assert_string(DESC, test.content_description);
assert_int(
Geary.Mime.DispositionType.ATTACHMENT,
test.content_disposition.disposition_type
);
assert_true(test.has_content_filename, "has_content_filename");
assert_string(test.content_filename, NAME, "content_filename");
}
public void new_from_inline_mime_part() throws Error {
GMime.Part part = new_part(null, ATTACHMENT_BODY.data);
part.set_content_disposition(
new GMime.ContentDisposition.from_string("inline")
);
Attachment test = new Attachment.from_part(
1, new Geary.RFC822.Part(part)
);
assert_int(
Geary.Mime.DispositionType.INLINE,
test.content_disposition.disposition_type
);
}
}
class Geary.ImapDB.AttachmentIoTest : TestCase {
private const string ENCODED_BODY = "This is an attachment.\r\n";
private const string DECODED_BODY = "This is an attachment.\n";
private GLib.File? tmp_dir;
private Geary.Db.Database? db;
public AttachmentIoTest() {
base("Geary.ImapDB.AttachmentIoTest");
add_test("save_minimal_attachment", save_minimal_attachment);
add_test("save_complete_attachment", save_complete_attachment);
add_test("save_qp_attachment", save_qp_attachment);
add_test("list_attachments", list_attachments);
add_test("delete_attachments", delete_attachments);
}
public override void set_up() throws Error {
this.tmp_dir = GLib.File.new_for_path(
GLib.DirUtils.make_tmp("geary-impadb-attachment-io-test-XXXXXX")
);
this.db = new Geary.Db.Database.transient();
this.db.open.begin(
Geary.Db.DatabaseFlags.NONE, null, null,
(obj, res) => { async_complete(res); }
);
this.db.open.end(async_result());
this.db.exec("""
CREATE TABLE MessageTable (
id INTEGER PRIMARY KEY
);
""");
this.db.exec("INSERT INTO MessageTable VALUES (1);");
this.db.exec("""
CREATE TABLE MessageAttachmentTable (
id INTEGER PRIMARY KEY,
message_id INTEGER REFERENCES MessageTable ON DELETE CASCADE,
filename TEXT,
mime_type TEXT,
filesize INTEGER,
disposition INTEGER,
content_id TEXT DEFAULT NULL,
description TEXT DEFAULT NULL
);
""");
}
public override void tear_down() throws Error {
this.db.close();
this.db = null;
Geary.Files.recursive_delete_async.begin(
this.tmp_dir,
null,
(obj, res) => { async_complete(res); }
);
Geary.Files.recursive_delete_async.end(async_result());
this.tmp_dir = null;
}
public void save_minimal_attachment() throws Error {
GMime.Part part = new_part(null, ENCODED_BODY.data);
Gee.List<Attachment> attachments = Attachment.save_attachments(
this.db.get_master_connection(),
this.tmp_dir,
1,
new Gee.ArrayList<Geary.RFC822.Part>.wrap({
new Geary.RFC822.Part(part)
}),
null
);
assert_int(1, attachments.size, "No attachment provided");
Geary.Attachment attachment = attachments[0];
assert_non_null(attachment.file, "Attachment file");
assert_int(
DECODED_BODY.data.length,
(int) attachment.filesize,
"Attachment file size"
);
uint8[] buf = new uint8[4096];
size_t len = 0;
attachments[0].file.read().read_all(buf, out len);
assert_string(DECODED_BODY, (string) buf[0:len]);
Geary.Db.Result result = this.db.query(
"SELECT * FROM MessageAttachmentTable;"
);
assert_false(result.finished, "Row not inserted");
assert_int(1, result.int_for("message_id"), "Row message id");
assert_int(
DECODED_BODY.data.length,
result.int_for("filesize"),
"Row file size"
);
assert_false(result.next(), "Multiple rows inserted");
}
public void save_complete_attachment() throws Error {
const string TYPE = "text/plain";
const string ID = "test-id";
const string DESCRIPTION = "test description";
const Geary.Mime.DispositionType DISPOSITION_TYPE =
Geary.Mime.DispositionType.INLINE;
const string FILENAME = "test.txt";
GMime.Part part = new_part(TYPE, ENCODED_BODY.data);
part.set_content_id(ID);
part.set_content_description(DESCRIPTION);
part.set_content_disposition(
new GMime.ContentDisposition.from_string(
"inline; filename=%s;".printf(FILENAME)
));
Gee.List<Attachment> attachments = Attachment.save_attachments(
this.db.get_master_connection(),
this.tmp_dir,
1,
new Gee.ArrayList<Geary.RFC822.Part>.wrap({
new Geary.RFC822.Part(part)
}),
null
);
assert_int(1, attachments.size, "No attachment provided");
Geary.Attachment attachment = attachments[0];
assert_string(TYPE, attachment.content_type.to_string());
assert_string(ID, attachment.content_id);
assert_string(DESCRIPTION, attachment.content_description);
assert_string(FILENAME, attachment.content_filename);
assert_int(
DISPOSITION_TYPE,
attachment.content_disposition.disposition_type,
"Attachment disposition type"
);
uint8[] buf = new uint8[4096];
size_t len = 0;
attachment.file.read().read_all(buf, out len);
assert_string(DECODED_BODY, (string) buf[0:len]);
Geary.Db.Result result = this.db.query(
"SELECT * FROM MessageAttachmentTable;"
);
assert_false(result.finished, "Row not inserted");
assert_int(1, result.int_for("message_id"), "Row message id");
assert_string(TYPE, result.string_for("mime_type"));
assert_string(ID, result.string_for("content_id"));
assert_string(DESCRIPTION, result.string_for("description"));
assert_int(
DISPOSITION_TYPE,
result.int_for("disposition"),
"Row disposition type"
);
assert_string(FILENAME, result.string_for("filename"));
assert_false(result.next(), "Multiple rows inserted");
}
public void save_qp_attachment() throws Error {
// Example courtesy https://en.wikipedia.org/wiki/Quoted-printable
const string QP_ENCODED =
"""J'interdis aux marchands de vanter trop leur marchandises. Car ils se font =
vite p=C3=A9dagogues et t'enseignent comme but ce qui n'est par essence qu'=
un moyen, et te trompant ainsi sur la route =C3=A0 suivre les voil=C3=A0 bi=
ent=C3=B4t qui te d=C3=A9gradent, car si leur musique est vulgaire ils te f=
abriquent pour te la vendre une =C3=A2me vulgaire.""";
const string QP_DECODED =
"""J'interdis aux marchands de vanter trop leur marchandises. Car ils se font vite pédagogues et t'enseignent comme but ce qui n'est par essence qu'un moyen, et te trompant ainsi sur la route à suivre les voilà bientôt qui te dégradent, car si leur musique est vulgaire ils te fabriquent pour te la vendre une âme vulgaire.""";
GMime.Part part = new_part(
"text/plain; charset=utf-8",
QP_ENCODED.data,
GMime.ContentEncoding.QUOTEDPRINTABLE
);
Gee.List<Attachment> attachments = Attachment.save_attachments(
this.db.get_master_connection(),
this.tmp_dir,
1,
new Gee.ArrayList<Geary.RFC822.Part>.wrap({
new Geary.RFC822.Part(part)
}),
null
);
assert_int(1, attachments.size, "No attachment provided");
uint8[] buf = new uint8[4096];
size_t len = 0;
attachments[0].file.read().read_all(buf, out len);
assert_string(QP_DECODED, (string) buf[0:len]);
}
public void list_attachments() throws Error {
this.db.exec("""
INSERT INTO MessageAttachmentTable ( message_id, mime_type )
VALUES (1, 'text/plain');
""");
this.db.exec("""
INSERT INTO MessageAttachmentTable ( message_id, mime_type )
VALUES (2, 'text/plain');
""");
Gee.List<Attachment> loaded = Attachment.list_attachments(
this.db.get_master_connection(),
GLib.File.new_for_path("/tmp"),
1,
null
);
assert_int(1, loaded.size, "Expected one row loaded");
assert_int(1, (int) loaded[0].message_id, "Unexpected message id");
}
public void delete_attachments() throws Error {
GMime.Part part = new_part(null, ENCODED_BODY.data);
Gee.List<Attachment> attachments = Attachment.save_attachments(
this.db.get_master_connection(),
this.tmp_dir,
1,
new Gee.ArrayList<Geary.RFC822.Part>.wrap({
new Geary.RFC822.Part(part)
}),
null
);
assert_true(attachments[0].file.query_exists(null),
"Attachment not saved to disk");
this.db.exec("""
INSERT INTO MessageAttachmentTable ( message_id, mime_type )
VALUES (2, 'text/plain');
""");
Attachment.delete_attachments(
this.db.get_master_connection(), this.tmp_dir, 1, null
);
Geary.Db.Result result = this.db.query(
"SELECT * FROM MessageAttachmentTable;"
);
assert_false(result.finished);
assert_int(2, result.int_for("message_id"), "Unexpected message_id");
assert_false(result.next(), "Attachment not deleted from db");
assert_false(attachments[0].file.query_exists(null),
"Attachment not deleted from disk");
}
}
private GMime.Part new_part(string? mime_type,
uint8[] body,
GMime.ContentEncoding encoding = GMime.ContentEncoding.DEFAULT) {
GMime.Part part = new GMime.Part();
if (mime_type != null) {
part.set_content_type(new GMime.ContentType.from_string(mime_type));
}
GMime.DataWrapper body_wrapper = new GMime.DataWrapper.with_stream(
new GMime.StreamMem.with_buffer(body),
encoding
);
part.set_content_object(body_wrapper);
return part;
}

View file

@ -0,0 +1,158 @@
/*
* 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.
*/
class Geary.ImapDB.DatabaseTest : TestCase {
public DatabaseTest() {
base("Geary.ImapDb.DatabaseTest");
add_test("open_new", open_new);
add_test("upgrade_0_6", upgrade_0_6);
}
public void open_new() throws Error {
GLib.File tmp_dir = GLib.File.new_for_path(
GLib.DirUtils.make_tmp("geary-db-database-test-XXXXXX")
);
Database db = new Database(
tmp_dir.get_child("test.db"),
GLib.File.new_for_path(_SOURCE_ROOT_DIR).get_child("sql"),
tmp_dir.get_child("attachments"),
new Geary.SimpleProgressMonitor(Geary.ProgressType.DB_UPGRADE),
new Geary.SimpleProgressMonitor(Geary.ProgressType.DB_VACUUM),
"test@example.com"
);
db.open.begin(
Geary.Db.DatabaseFlags.CREATE_FILE, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
// Need to get a connection since the database doesn't
// actually get created until then
db.get_master_connection();
// Need to close it again to stop the GC process running
db.close();
db.file.delete();
tmp_dir.delete();
}
public void upgrade_0_6() throws Error {
GLib.File tmp_dir = GLib.File.new_for_path(
GLib.DirUtils.make_tmp("geary-db-database-test-XXXXXX")
);
// Since the upgrade process also messes around with
// attachments on disk which we want to be able to test, we
// need to have a complete-ish database and attachments
// directory hierarchy. For convenience, these are included as
// a single compressed archive, but that means we need to
// un-compress and unpack the archive as part of the test
// fixture.
const string DB_0_6_RESOURCE = "geary-0.6-db.tar.xz";
const string DB_0_6_DIR = "geary-0.6-db";
const string ATTACHMENT_12 = "capitalism.jpeg";
GLib.File db_archive = GLib.File
.new_for_uri(RESOURCE_URI)
.resolve_relative_path(DB_0_6_RESOURCE);
GLib.File db_dir = tmp_dir.get_child(DB_0_6_DIR);
GLib.File db_file = db_dir.get_child("geary.db");
GLib.File attachments_dir = db_dir.get_child("attachments");
unpack_archive(db_archive, tmp_dir);
// This number is the id of the last known message in the
// database
GLib.File message_dir = attachments_dir.get_child("43");
// Ensure one of the expected attachments exists up
// front. Since there are 12 known attachments, 12 should be
// the last one in the table and exist on the file system,
// while 13 should not.
assert_true(
message_dir.get_child("12").get_child(ATTACHMENT_12).query_exists(),
"Expected attachment file"
);
assert_false(
message_dir.get_child("13").query_exists(),
"Unexpected attachment file"
);
Database db = new Database(
db_file,
GLib.File.new_for_path(_SOURCE_ROOT_DIR).get_child("sql"),
attachments_dir,
new Geary.SimpleProgressMonitor(Geary.ProgressType.DB_UPGRADE),
new Geary.SimpleProgressMonitor(Geary.ProgressType.DB_VACUUM),
"test@example.com"
);
db.open.begin(
Geary.Db.DatabaseFlags.CREATE_FILE, null,
(obj, ret) => { async_complete(ret); }
);
db.open.end(async_result());
assert_int(25, db.get_schema_version(), "Post-upgrade version");
// Since schema v22 deletes the re-creates all attachments,
// attachment 12 should no longer exist on the file system and
// there should be an attachment with id 24.
assert_false(
message_dir.get_child("12").get_child(ATTACHMENT_12).query_exists(),
"Old attachment file not deleted"
);
assert_true(
message_dir.get_child("24").get_child(ATTACHMENT_12).query_exists(),
"New attachment dir/file not created"
);
// Need to close it again to stop the GC process running
db.close();
Geary.Files.recursive_delete_async.begin(
tmp_dir, null,
(obj, res) => { async_complete(res); }
);
Geary.Files.recursive_delete_async.end(async_result());
}
private void unpack_archive(GLib.File archive, GLib.File dest)
throws Error {
// GLib doesn't seem to have native support for unpacking
// multi-file archives however, so use this fun kludge
// instead.
GLib.InputStream bytes = archive.read();
GLib.Subprocess untar = new GLib.Subprocess(
GLib.SubprocessFlags.STDIN_PIPE,
"tar", "-xJf", "-", "-C", dest.get_path()
);
GLib.OutputStream stdin = untar.get_stdin_pipe();
uint8[] buf = new uint8[4096];
ssize_t len = 0;
do {
len = bytes.read(buf);
stdin.write(buf[0:len]);
} while (len > 0);
stdin.close();
untar.wait();
}
}

View file

@ -9,14 +9,21 @@ class Geary.Mime.ContentTypeTest : TestCase {
public ContentTypeTest() {
base("Geary.Mime.ContentTypeTest");
add_test("is_default", is_default);
add_test("static_defaults", static_defaults);
add_test("get_file_name_extension", get_file_name_extension);
add_test("guess_type_from_name", guess_type_from_name);
add_test("guess_type_from_buf", guess_type_from_buf);
}
public void is_default() throws Error {
assert(new ContentType("application", "octet-stream", null).is_default());
public void static_defaults() throws Error {
assert_string(
"text/plain; charset=us-ascii",
ContentType.DISPLAY_DEFAULT.to_string()
);
assert_string(
"application/octet-stream",
ContentType.ATTACHMENT_DEFAULT.to_string()
);
}
public void get_file_name_extension() throws Error {
@ -25,17 +32,15 @@ class Geary.Mime.ContentTypeTest : TestCase {
}
public void guess_type_from_name() throws Error {
try {
assert(ContentType.guess_type("test.png", null).is_type("image", "png"));
} catch (Error err) {
assert_not_reached();
}
try {
assert(ContentType.guess_type("foo.test", null).get_mime_type() == ContentType.DEFAULT_CONTENT_TYPE);
} catch (Error err) {
assert_not_reached();
}
assert_true(
ContentType.guess_type("test.png", null).is_type("image", "png"),
"Expected image/png"
);
assert_true(
ContentType.guess_type("foo.test", null)
.is_same(ContentType.ATTACHMENT_DEFAULT),
"Expected ContentType.ATTACHMENT_DEFAULT"
);
}
public void guess_type_from_buf() throws Error {
@ -44,17 +49,15 @@ class Geary.Mime.ContentTypeTest : TestCase {
);
Memory.ByteBuffer empty = new Memory.ByteBuffer({0x0}, 1);
try {
assert(ContentType.guess_type(null, png).is_type("image", "png"));
} catch (Error err) {
assert_not_reached();
}
try {
assert(ContentType.guess_type(null, empty).get_mime_type() == ContentType.DEFAULT_CONTENT_TYPE);
} catch (Error err) {
assert_not_reached();
}
assert_true(
ContentType.guess_type(null, png).is_type("image", "png"),
"Expected image/png"
);
assert_true(
ContentType.guess_type(null, empty)
.is_same(ContentType.ATTACHMENT_DEFAULT),
"Expected ContentType.ATTACHMENT_DEFAULT"
);
}
}

View file

@ -14,30 +14,30 @@ class Geary.RFC822.MessageDataTest : TestCase {
public void preview_text_with_header() throws Error {
PreviewText plain_preview1 = new PreviewText.with_header(
new Geary.Memory.StringBuffer(PLAIN_BODY1_ENCODED),
new Geary.Memory.StringBuffer(PLAIN_BODY1_HEADERS)
new Geary.Memory.StringBuffer(PLAIN_BODY1_HEADERS),
new Geary.Memory.StringBuffer(PLAIN_BODY1_ENCODED)
);
assert(plain_preview1.buffer.to_string() == PLAIN_BODY1_EXPECTED);
assert_string(PLAIN_BODY1_EXPECTED, plain_preview1.buffer.to_string());
PreviewText base64_preview = new PreviewText.with_header(
new Geary.Memory.StringBuffer(BASE64_BODY_ENCODED),
new Geary.Memory.StringBuffer(BASE64_BODY_HEADERS)
new Geary.Memory.StringBuffer(BASE64_BODY_HEADERS),
new Geary.Memory.StringBuffer(BASE64_BODY_ENCODED)
);
assert(base64_preview.buffer.to_string() == BASE64_BODY_EXPECTED);
assert_string(BASE64_BODY_EXPECTED, base64_preview.buffer.to_string());
string html_part_headers = "Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n";
PreviewText html_preview1 = new PreviewText.with_header(
new Geary.Memory.StringBuffer(HTML_BODY1_ENCODED),
new Geary.Memory.StringBuffer(html_part_headers)
new Geary.Memory.StringBuffer(html_part_headers),
new Geary.Memory.StringBuffer(HTML_BODY1_ENCODED)
);
assert(html_preview1.buffer.to_string() == HTML_BODY1_EXPECTED);
assert_string(HTML_BODY1_EXPECTED, html_preview1.buffer.to_string());
PreviewText html_preview2 = new PreviewText.with_header(
new Geary.Memory.StringBuffer(HTML_BODY2_ENCODED),
new Geary.Memory.StringBuffer(html_part_headers)
new Geary.Memory.StringBuffer(html_part_headers),
new Geary.Memory.StringBuffer(HTML_BODY2_ENCODED)
);
assert(html_preview2.buffer.to_string() == HTML_BODY2_EXPECTED);
assert_string(HTML_BODY2_EXPECTED, html_preview2.buffer.to_string());
}
public static string PLAIN_BODY1_HEADERS = "Content-Type: text/plain; charset=\"us-ascii\"\r\nContent-Transfer-Encoding: 7bit\r\n";

View file

@ -7,29 +7,56 @@
class Geary.RFC822.MessageTest : TestCase {
private const string BASIC_TEXT_PLAIN = "basic-text-plain.eml";
private const string BASIC_TEXT_HTML = "basic-text-html.eml";
private const string BASIC_MULTIPART_ALTERNATIVE =
"basic-multipart-alternative.eml";
private const string HTML_CONVERSION_TEMPLATE =
"<div class=\"plaintext\" style=\"white-space: pre-wrap;\">%s</div>";
private const string BASIC_PLAIN_BODY = """This is the first line.
This is the second line.
""";
private const string BASIC_HTML_BODY = """<P>This is the first line.
<P>This is the second line.
""";
public MessageTest() {
base("Geary.RFC822.MessageTest");
add_test("basic_message_from_buffer", basic_message_from_buffer);
add_test("encoded_recipient", encoded_recipient);
add_test("duplicate_mailbox", duplicate_mailbox);
add_test("duplicate_message_id", duplicate_message_id);
add_test("text_plain_as_plain", text_plain_as_plain);
add_test("text_plain_as_html", text_plain_as_html);
add_test("text_html_as_html", text_html_as_html);
add_test("text_html_as_plain", text_html_as_plain);
add_test("multipart_alternative_as_plain",
multipart_alternative_as_plain);
add_test("multipart_alternative_as_converted_html",
multipart_alternative_as_converted_html);
add_test("multipart_alternative_as_html",
multipart_alternative_as_html);
add_test("get_preview", get_preview);
}
public void basic_message_from_buffer() throws Error {
Message? basic = null;
try {
basic = string_to_message(BASIC_MESSAGE);
} catch (Error err) {
assert_no_error(err);
}
assert_data(basic.subject, "Re: Saying Hello");
assert_addresses(basic.from, "Mary Smith <mary@example.net>");
assert_address(basic.sender, "Mary Smith Sender <mary@example.net>");
assert_addresses(basic.reply_to, "\"Mary Smith: Personal Account\" <smith@home.example>");
assert_addresses(basic.to, "John Doe <jdoe@machine.example>");
assert_addresses(basic.cc, "John Doe CC <jdoe@machine.example>");
assert_addresses(basic.bcc, "John Doe BCC <jdoe@machine.example>");
Message basic = resource_to_message(BASIC_TEXT_PLAIN);
assert_data(basic.subject, "Re: Basic text/plain message");
assert_addresses(basic.from, "Alice <alice@example.net>");
assert_address(basic.sender, "Bob <bob@example.net>");
assert_addresses(basic.reply_to, "\"Alice: Personal Account\" <alice@example.org>");
assert_addresses(basic.to, "Charlie <charlie@example.net>");
assert_addresses(basic.cc, "Dave <dave@example.net>");
assert_addresses(basic.bcc, "Eve <eve@example.net>");
//assert_data(basic.message_id, "<3456@example.net>");
assert_message_id_list(basic.in_reply_to, "<1234@local.machine.example>");
assert_message_id_list(basic.references, "<1234@local.machine.example>");
@ -38,24 +65,14 @@ class Geary.RFC822.MessageTest : TestCase {
}
public void encoded_recipient() throws Error {
Message? enc = null;
try {
enc = string_to_message(ENCODED_TO);
} catch (Error err) {
assert_no_error(err);
}
Message enc = string_to_message(ENCODED_TO);
// Courtesy Mailsploit https://www.mailsploit.com
assert(enc.to[0].name == "potus@whitehouse.gov <test>");
}
public void duplicate_mailbox() throws Error {
Message? dup = null;
try {
dup = string_to_message(DUPLICATE_TO);
} catch (Error err) {
assert_no_error(err);
}
Message dup = string_to_message(DUPLICATE_TO);
assert(dup.to.size == 2);
assert_addresses(
@ -64,12 +81,7 @@ class Geary.RFC822.MessageTest : TestCase {
}
public void duplicate_message_id() throws Error {
Message? dup = null;
try {
dup = string_to_message(DUPLICATE_REFERENCES);
} catch (Error err) {
assert_no_error(err);
}
Message dup = string_to_message(DUPLICATE_REFERENCES);
assert(dup.references.list.size == 2);
assert_message_id_list(
@ -77,13 +89,84 @@ class Geary.RFC822.MessageTest : TestCase {
);
}
public void text_plain_as_plain() throws Error {
Message test = resource_to_message(BASIC_TEXT_PLAIN);
assert_true(test.has_plain_body(), "Expected plain body");
assert_false(test.has_html_body(), "Expected non-html body");
assert_string(BASIC_PLAIN_BODY, test.get_plain_body(false, null));
}
public void text_plain_as_html() throws Error {
Message test = resource_to_message(BASIC_TEXT_PLAIN);
assert_true(test.has_plain_body(), "Expected plain body");
assert_false(test.has_html_body(), "Expected non-html body");
assert_string(
HTML_CONVERSION_TEMPLATE.printf(BASIC_PLAIN_BODY),
test.get_plain_body(true, null)
);
}
public void text_html_as_html() throws Error {
Message test = resource_to_message(BASIC_TEXT_HTML);
assert_true(test.has_html_body(), "Expected html body");
assert_false(test.has_plain_body(), "Expected non-plain body");
assert_string(BASIC_HTML_BODY, test.get_html_body(null));
}
public void text_html_as_plain() throws Error {
Message test = resource_to_message(BASIC_TEXT_HTML);
assert_true(test.has_html_body(), "Expected html body");
assert_false(test.has_plain_body(), "Expected non-plain body");
assert_string(BASIC_HTML_BODY, test.get_html_body(null));
}
public void multipart_alternative_as_plain() throws Error {
Message test = resource_to_message(BASIC_MULTIPART_ALTERNATIVE);
assert_true(test.has_plain_body(), "Expected plain body");
assert_true(test.has_html_body(), "Expected html body");
assert_string(BASIC_PLAIN_BODY, test.get_plain_body(false, null));
}
public void multipart_alternative_as_converted_html() throws Error {
Message test = resource_to_message(BASIC_MULTIPART_ALTERNATIVE);
assert_true(test.has_plain_body(), "Expected plain body");
assert_true(test.has_html_body(), "Expected html body");
assert_string(
HTML_CONVERSION_TEMPLATE.printf(BASIC_PLAIN_BODY),
test.get_plain_body(true, null)
);
}
public void multipart_alternative_as_html() throws Error {
Message test = resource_to_message(BASIC_MULTIPART_ALTERNATIVE);
assert_true(test.has_plain_body(), "Expected plain body");
assert_true(test.has_html_body(), "Expected html body");
assert_string(BASIC_HTML_BODY, test.get_html_body(null));
}
public void get_preview() throws Error {
try {
Message multipart_signed = string_to_message(MULTIPART_SIGNED_MESSAGE_TEXT);
assert(multipart_signed.get_preview() == MULTIPART_SIGNED_MESSAGE_PREVIEW);
} catch (Error err) {
assert_no_error(err);
}
Message multipart_signed = string_to_message(MULTIPART_SIGNED_MESSAGE_TEXT);
assert(multipart_signed.get_preview() == MULTIPART_SIGNED_MESSAGE_PREVIEW);
}
private Message resource_to_message(string path) throws Error {
GLib.File resource =
GLib.File.new_for_uri(RESOURCE_URI).resolve_relative_path(path);
uint8[] contents;
resource.load_contents(null, out contents, null);
return new Message.from_buffer(
new Geary.Memory.ByteBuffer(contents, contents.length)
);
}
private Message string_to_message(string message_text) throws Error {
@ -92,28 +175,34 @@ class Geary.RFC822.MessageTest : TestCase {
);
}
private void assert_data(Geary.MessageData.AbstractMessageData? data, string expected) {
assert(data != null);
assert(data.to_string() == expected);
private void assert_data(Geary.MessageData.AbstractMessageData? actual,
string expected)
throws Error {
assert_non_null(actual, expected);
assert_string(expected, actual.to_string());
}
private void assert_address(Geary.RFC822.MailboxAddress? address, string expected) {
assert(address != null);
assert(address.to_rfc822_string() == expected);
private void assert_address(Geary.RFC822.MailboxAddress? address,
string expected)
throws Error {
assert_non_null(address, expected);
assert_string(expected, address.to_rfc822_string());
}
private void assert_addresses(Geary.RFC822.MailboxAddresses? addresses, string expected) {
assert(addresses != null);
assert(addresses.to_rfc822_string() == expected);
private void assert_addresses(Geary.RFC822.MailboxAddresses? addresses,
string expected)
throws Error {
assert_non_null(addresses, expected);
assert_string(expected, addresses.to_rfc822_string());
}
private void assert_message_id_list(Geary.RFC822.MessageIDList? ids, string expected) {
assert(ids != null);
private void assert_message_id_list(Geary.RFC822.MessageIDList? ids,
string expected)
throws Error {
assert_non_null(ids, expected);
assert(ids.to_rfc822_string() == expected);
}
private static string BASIC_MESSAGE = "From: Mary Smith <mary@example.net>\r\nSender: Mary Smith Sender <mary@example.net>\r\nTo: John Doe <jdoe@machine.example>\r\nCC: John Doe CC <jdoe@machine.example>\r\nBCC: John Doe BCC <jdoe@machine.example>\r\nReply-To: \"Mary Smith: Personal Account\" <smith@home.example>\r\nSubject: Re: Saying Hello\r\nDate: Fri, 21 Nov 1997 10:01:10 -0600\r\nMessage-ID: <3456@example.net>\r\nIn-Reply-To: <1234@local.machine.example>\r\nReferences: <1234@local.machine.example>\r\nX-Mailer: Geary Test Suite 1.0\r\n\r\nThis is a reply to your hello.\r\n\r\n";
// Courtesy Mailsploit https://www.mailsploit.com
private static string ENCODED_TO = "From: Mary Smith <mary@example.net>\r\nTo: =?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3YiIDx0ZXN0Pg==?= <jdoe@machine.example>\r\nSubject: Re: Saying Hello\r\nDate: Fri, 21 Nov 1997 10:01:10 -0600\r\n\r\nThis is a reply to your hello.\r\n\r\n";

View file

@ -0,0 +1,71 @@
/*
* 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.
*/
class Geary.RFC822.PartTest : TestCase {
private const string BODY = "This is an attachment.\n";
public PartTest() {
base("Geary.RFC822.PartTest");
add_test("new_from_empty_mime_part", new_from_empty_mime_part);
add_test("new_from_complete_mime_part", new_from_complete_mime_part);
}
public void new_from_empty_mime_part() throws Error {
GMime.Part part = new_part(null, BODY.data);
part.set_header("Content-Type", "");
Part test = new Part(part);
assert_null(test.content_type, "content_type");
assert_null_string(test.content_id, "content_id");
assert_null_string(test.content_description, "content_description");
assert_null(test.content_disposition, "content_disposition");
}
public void new_from_complete_mime_part() throws Error {
const string TYPE = "text/plain";
const string ID = "test-id";
const string DESC = "test description";
GMime.Part part = new_part(TYPE, BODY.data);
part.set_content_id(ID);
part.set_content_description(DESC);
part.set_content_disposition(
new GMime.ContentDisposition.from_string("inline")
);
Part test = new Part(part);
assert_string(TYPE, test.content_type.to_string());
assert_string(ID, test.content_id);
assert_string(DESC, test.content_description);
assert_non_null(test.content_disposition, "content_disposition");
assert_int(
Geary.Mime.DispositionType.INLINE,
test.content_disposition.disposition_type
);
}
private GMime.Part new_part(string? mime_type,
uint8[] body,
GMime.ContentEncoding encoding = GMime.ContentEncoding.DEFAULT) {
GMime.Part part = new GMime.Part();
if (mime_type != null) {
part.set_content_type(new GMime.ContentType.from_string(mime_type));
}
GMime.DataWrapper body_wrapper = new GMime.DataWrapper.with_stream(
new GMime.StreamMem.with_buffer(body),
encoding
);
part.set_content_object(body_wrapper);
part.encode(GMime.EncodingConstraint.7BIT);
return part;
}
}

View file

@ -1,3 +1,5 @@
subdir('data')
geary_test_lib_sources = [
'mock-object.vala',
'test-case.vala',
@ -22,15 +24,20 @@ geary_test_engine_sources = [
'engine/app/app-conversation-test.vala',
'engine/app/app-conversation-monitor-test.vala',
'engine/app/app-conversation-set-test.vala',
'engine/db/db-database-test.vala',
'engine/db/db-versioned-database-test.vala',
'engine/imap/command/imap-create-command-test.vala',
'engine/imap/response/imap-namespace-response-test.vala',
'engine/imap/transport/imap-deserializer-test.vala',
'engine/imap-db/imap-db-attachment-test.vala',
'engine/imap-db/imap-db-database-test.vala',
'engine/imap-engine/account-processor-test.vala',
'engine/mime-content-type-test.vala',
'engine/rfc822-mailbox-address-test.vala',
'engine/rfc822-mailbox-addresses-test.vala',
'engine/rfc822-message-test.vala',
'engine/rfc822-message-data-test.vala',
'engine/rfc822-part-test.vala',
'engine/rfc822-utils-test.vala',
'engine/util-ascii-test.vala',
'engine/util-html-test.vala',
@ -38,7 +45,9 @@ geary_test_engine_sources = [
'engine/util-inet-test.vala',
'engine/util-js-test.vala',
'engine/util-string-test.vala',
'engine/util-timeout-manager-test.vala'
'engine/util-timeout-manager-test.vala',
geary_test_engine_resources
]
geary_test_client_sources = [

View file

@ -62,6 +62,18 @@ public void assert_string(string expected, string? actual, string? context = nul
}
}
public void assert_null_string(string? actual, string? context = null)
throws Error {
if (actual != null) {
string a = actual;
if (a.length > 70) {
a = a[0:70] + "";
}
print_assert("Expected: null, was: \"%s\"".printf(a), context);
assert_not_reached();
}
}
public void assert_int(int expected, int actual, string? context = null)
throws Error {
if (expected != actual) {
@ -140,9 +152,14 @@ private inline void print_assert(string message, string? context) {
GLib.stderr.putc('\n');
}
public abstract class TestCase : Object {
/** GLib.File URI for resources in test/data. */
public const string RESOURCE_URI = "resource:///org/gnome/GearyTest";
private class SignalWaiter : Object {
public bool was_fired = false;

View file

@ -31,10 +31,15 @@ int main(string[] args) {
// Depends on ConversationTest and ConversationSetTest passing
engine.add_suite(new Geary.App.ConversationMonitorTest().get_suite());
engine.add_suite(new Geary.Ascii.Test().get_suite());
engine.add_suite(new Geary.Db.DatabaseTest().get_suite());
engine.add_suite(new Geary.Db.VersionedDatabaseTest().get_suite());
engine.add_suite(new Geary.HTML.UtilTest().get_suite());
engine.add_suite(new Geary.Imap.DeserializerTest().get_suite());
engine.add_suite(new Geary.Imap.CreateCommandTest().get_suite());
engine.add_suite(new Geary.Imap.NamespaceResponseTest().get_suite());
engine.add_suite(new Geary.ImapDB.AttachmentTest().get_suite());
engine.add_suite(new Geary.ImapDB.AttachmentIoTest().get_suite());
engine.add_suite(new Geary.ImapDB.DatabaseTest().get_suite());
engine.add_suite(new Geary.ImapEngine.AccountProcessorTest().get_suite());
engine.add_suite(new Geary.Inet.Test().get_suite());
engine.add_suite(new Geary.JS.Test().get_suite());