geary/src/engine/imap-db/imap-db-account.vala
Charles Lindsay fca993fec7 Merge branch 'master' into feature/search
Conflicts:
	sql/version-010.sql
	src/client/folder-list/folder-list-folder-entry.vala
	src/engine/rfc822/rfc822-message.vala

Also, I had to manually fix some compile errors introduced due to
interfaces changing on master.
2013-06-24 17:54:41 -07:00

1094 lines
46 KiB
Vala

/* Copyright 2011-2013 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
private class Geary.ImapDB.Account : BaseObject {
private class FolderReference : Geary.SmartReference {
public Geary.FolderPath path;
public FolderReference(ImapDB.Folder folder, Geary.FolderPath path) {
base (folder);
this.path = path;
}
}
private class SearchOffset {
public int column; // Column in search table
public int byte_offset; // Offset (in bytes) of search term in string
public int size; // Size (in bytes) of the search term in string
public SearchOffset(string[] offset_string) {
column = int.parse(offset_string[0]);
byte_offset = int.parse(offset_string[2]);
size = int.parse(offset_string[3]);
}
}
// Only available when the Account is opened
public SmtpOutboxFolder? outbox { get; private set; default = null; }
public SearchFolder? search_folder { get; private set; default = null; }
private string name;
private AccountInformation account_information;
private ImapDB.Database? db = null;
private Gee.HashMap<Geary.FolderPath, FolderReference> folder_refs =
new Gee.HashMap<Geary.FolderPath, FolderReference>();
private Cancellable? background_cancellable = null;
public ImapEngine.ContactStore contact_store { get; private set; }
public IntervalProgressMonitor search_index_monitor { get; private set;
default = new IntervalProgressMonitor(ProgressType.SEARCH_INDEX, 0, 0); }
public Account(Geary.AccountInformation account_information) {
this.account_information = account_information;
contact_store = new ImapEngine.ContactStore(this);
name = "IMAP database account for %s".printf(account_information.imap_credentials.user);
}
private void check_open() throws Error {
if (db == null)
throw new EngineError.OPEN_REQUIRED("Database not open");
}
public async void open_async(File user_data_dir, File schema_dir, Cancellable? cancellable)
throws Error {
if (db != null)
throw new EngineError.ALREADY_OPEN("IMAP database already open");
db = new ImapDB.Database(user_data_dir, schema_dir, account_information.email);
try {
db.open(Db.DatabaseFlags.CREATE_DIRECTORY | Db.DatabaseFlags.CREATE_FILE, null,
cancellable);
} catch (Error err) {
warning("Unable to open database: %s", err.message);
// close database before exiting
db = null;
throw err;
}
Geary.Account account;
try {
account = Geary.Engine.instance.get_account_instance(account_information);
} catch (Error e) {
// If they're opening an account, the engine should already be
// open, and there should be no reason for this to fail. Thus, if
// we get here, it's a programmer error.
error("Error finding account from its information: %s", e.message);
}
background_cancellable = new Cancellable();
// Kick off a background update of the search table.
populate_search_table_async.begin(background_cancellable);
initialize_contacts(cancellable);
// ImapDB.Account holds the Outbox, which is tied to the database it maintains
outbox = new SmtpOutboxFolder(db, account);
// Search folder
search_folder = new SearchFolder(account);
}
public async void close_async(Cancellable? cancellable) throws Error {
if (db == null)
return;
// close and always drop reference
try {
db.close(cancellable);
} finally {
db = null;
}
background_cancellable.cancel();
background_cancellable = null;
outbox = null;
search_folder = null;
}
public async void clone_folder_async(Geary.Imap.Folder imap_folder, Cancellable? cancellable = null)
throws Error {
check_open();
Geary.Imap.FolderProperties properties = imap_folder.properties;
Geary.FolderPath path = imap_folder.path;
yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
// get the parent of this folder, creating parents if necessary ... ok if this fails,
// that just means the folder has no parents
int64 parent_id = Db.INVALID_ROWID;
if (!do_fetch_parent_id(cx, path, true, out parent_id, cancellable)) {
debug("Unable to find parent ID to %s clone folder", path.to_string());
return Db.TransactionOutcome.ROLLBACK;
}
// create the folder object
Db.Statement stmt = cx.prepare(
"INSERT INTO FolderTable (name, parent_id, last_seen_total, last_seen_status_total, "
+ "uid_validity, uid_next, attributes, unread_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
stmt.bind_string(0, path.basename);
stmt.bind_rowid(1, parent_id);
stmt.bind_int(2, Numeric.int_floor(properties.select_examine_messages, 0));
stmt.bind_int(3, Numeric.int_floor(properties.status_messages, 0));
stmt.bind_int64(4, (properties.uid_validity != null) ? properties.uid_validity.value
: Imap.UIDValidity.INVALID);
stmt.bind_int64(5, (properties.uid_next != null) ? properties.uid_next.value
: Imap.UID.INVALID);
stmt.bind_string(6, properties.attrs.serialize());
stmt.bind_int(7, properties.email_unread);
stmt.exec(cancellable);
return Db.TransactionOutcome.COMMIT;
}, cancellable);
}
/**
* Only updates folder's STATUS message count, attributes, recent, and unseen; UIDVALIDITY and UIDNEXT
* updated when the folder is SELECT/EXAMINED (see update_folder_select_examine_async())
*/
public async void update_folder_status_async(Geary.Imap.Folder imap_folder, Cancellable? cancellable)
throws Error {
check_open();
Geary.Imap.FolderProperties properties = imap_folder.properties;
Geary.FolderPath path = imap_folder.path;
yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
int64 parent_id;
if (!do_fetch_parent_id(cx, path, true, out parent_id, cancellable)) {
debug("Unable to find parent ID of %s to update properties", path.to_string());
return Db.TransactionOutcome.ROLLBACK;
}
Db.Statement stmt;
if (parent_id != Db.INVALID_ROWID) {
stmt = cx.prepare(
"UPDATE FolderTable SET attributes=?, unread_count=? WHERE parent_id=? AND name=?");
stmt.bind_string(0, properties.attrs.serialize());
stmt.bind_int(1, properties.email_unread);
stmt.bind_rowid(2, parent_id);
stmt.bind_string(3, path.basename);
} else {
stmt = cx.prepare(
"UPDATE FolderTable SET attributes=?, unread_count=? WHERE parent_id IS NULL AND name=?");
stmt.bind_string(0, properties.attrs.serialize());
stmt.bind_int(1, properties.email_unread);
stmt.bind_string(2, path.basename);
}
stmt.exec();
if (properties.status_messages >= 0) {
do_update_last_seen_status_total(cx, parent_id, path.basename, properties.status_messages,
cancellable);
}
return Db.TransactionOutcome.COMMIT;
}, cancellable);
// update appropriate properties in the local folder
ImapDB.Folder? db_folder = get_local_folder(path);
if (db_folder != null) {
Imap.FolderProperties local_properties = db_folder.get_properties();
local_properties.set_status_unseen(properties.unseen);
local_properties.recent = properties.recent;
local_properties.attrs = properties.attrs;
if (properties.status_messages >= 0)
local_properties.set_status_message_count(properties.status_messages, false);
}
}
/**
* Only updates folder's SELECT/EXAMINE message count, UIDVALIDITY, UIDNEXT, unseen, and recent.
* See also update_folder_status_async().
*/
public async void update_folder_select_examine_async(Geary.Imap.Folder imap_folder, Cancellable? cancellable)
throws Error {
check_open();
Geary.Imap.FolderProperties properties = imap_folder.properties;
Geary.FolderPath path = imap_folder.path;
yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
int64 parent_id;
if (!do_fetch_parent_id(cx, path, true, out parent_id, cancellable)) {
debug("Unable to find parent ID of %s to update properties", path.to_string());
return Db.TransactionOutcome.ROLLBACK;
}
int64 uid_validity = (properties.uid_validity != null) ? properties.uid_validity.value
: Imap.UIDValidity.INVALID;
int64 uid_next = (properties.uid_next != null) ? properties.uid_next.value
: Imap.UID.INVALID;
Db.Statement stmt;
if (parent_id != Db.INVALID_ROWID) {
stmt = cx.prepare(
"UPDATE FolderTable SET uid_validity=?, uid_next=? WHERE parent_id=? AND name=?");
stmt.bind_int64(0, uid_validity);
stmt.bind_int64(1, uid_next);
stmt.bind_rowid(2, parent_id);
stmt.bind_string(3, path.basename);
} else {
stmt = cx.prepare(
"UPDATE FolderTable SET uid_validity=?, uid_next=? WHERE parent_id IS NULL AND name=?");
stmt.bind_int64(0, uid_validity);
stmt.bind_int64(1, uid_next);
stmt.bind_string(2, path.basename);
}
stmt.exec();
if (properties.select_examine_messages >= 0) {
do_update_last_seen_select_examine_total(cx, parent_id, path.basename,
properties.select_examine_messages, cancellable);
}
return Db.TransactionOutcome.COMMIT;
}, cancellable);
// update appropriate properties in the local folder
ImapDB.Folder? db_folder = get_local_folder(path);
if (db_folder != null) {
Imap.FolderProperties local_properties = db_folder.get_properties();
local_properties.set_status_unseen(properties.unseen);
local_properties.recent = properties.recent;
local_properties.uid_validity = properties.uid_validity;
local_properties.uid_next = properties.uid_next;
if (properties.select_examine_messages >= 0)
local_properties.set_select_examine_message_count(properties.select_examine_messages);
}
}
private void initialize_contacts(Cancellable? cancellable = null) throws Error {
check_open();
Gee.Collection<Contact> contacts = new Gee.LinkedList<Contact>();
Db.TransactionOutcome outcome = db.exec_transaction(Db.TransactionType.RO,
(context) => {
Db.Statement statement = context.prepare(
"SELECT email, real_name, highest_importance, normalized_email, flags " +
"FROM ContactTable");
Db.Result result = statement.exec(cancellable);
while (!result.finished) {
try {
Contact contact = new Contact(result.string_at(0), result.string_at(1),
result.int_at(2), result.string_at(3), ContactFlags.deserialize(result.string_at(4)));
contacts.add(contact);
} catch (Geary.DatabaseError err) {
// We don't want to abandon loading all contacts just because there was a
// problem with one.
debug("Problem loading contact: %s", err.message);
}
result.next();
}
return Db.TransactionOutcome.DONE;
}, cancellable);
if (outcome == Db.TransactionOutcome.DONE)
contact_store.update_contacts(contacts);
}
public async Gee.Collection<Geary.ImapDB.Folder> list_folders_async(Geary.FolderPath? parent,
Cancellable? cancellable = null) throws Error {
check_open();
// TODO: A better solution here would be to only pull the FolderProperties if the Folder
// object itself doesn't already exist
Gee.HashMap<Geary.FolderPath, int64?> id_map = new Gee.HashMap<
Geary.FolderPath, int64?>();
Gee.HashMap<Geary.FolderPath, Geary.Imap.FolderProperties> prop_map = new Gee.HashMap<
Geary.FolderPath, Geary.Imap.FolderProperties>();
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
int64 parent_id = Db.INVALID_ROWID;
if (parent != null) {
if (!do_fetch_folder_id(cx, parent, false, out parent_id, cancellable)) {
debug("Unable to find folder ID for %s to list folders", parent.to_string());
return Db.TransactionOutcome.ROLLBACK;
}
if (parent_id == Db.INVALID_ROWID)
throw new EngineError.NOT_FOUND("Folder %s not found", parent.to_string());
}
Db.Statement stmt;
if (parent_id != Db.INVALID_ROWID) {
stmt = cx.prepare(
"SELECT id, name, last_seen_total, last_seen_status_total, uid_validity, uid_next, attributes "
+ "FROM FolderTable WHERE parent_id=?");
stmt.bind_rowid(0, parent_id);
} else {
stmt = cx.prepare(
"SELECT id, name, last_seen_total, last_seen_status_total, uid_validity, uid_next, attributes "
+ "FROM FolderTable WHERE parent_id IS NULL");
}
Db.Result result = stmt.exec(cancellable);
while (!result.finished) {
string basename = result.string_for("name");
Geary.FolderPath path = (parent != null)
? parent.get_child(basename)
: new Geary.FolderRoot(basename, "/", Geary.Imap.Folder.CASE_SENSITIVE);
Geary.Imap.FolderProperties properties = new Geary.Imap.FolderProperties(
result.int_for("last_seen_total"), 0,
new Imap.UIDValidity(result.int64_for("uid_validity")),
new Imap.UID(result.int64_for("uid_next")),
Geary.Imap.MailboxAttributes.deserialize(result.string_for("attributes")));
// due to legacy code, can't set last_seen_total to -1 to indicate that the folder
// hasn't been SELECT/EXAMINE'd yet, so the STATUS count should be used as the
// authoritative when the other is zero ... this is important when first creating a
// folder, as the STATUS is the count that is known first
properties.set_status_message_count(result.int_for("last_seen_status_total"),
(properties.select_examine_messages == 0));
id_map.set(path, result.rowid_for("id"));
prop_map.set(path, properties);
result.next(cancellable);
}
return Db.TransactionOutcome.DONE;
}, cancellable);
assert(id_map.size == prop_map.size);
if (id_map.size == 0) {
throw new EngineError.NOT_FOUND("No local folders in %s",
(parent != null) ? parent.to_string() : "root");
}
Gee.Collection<Geary.ImapDB.Folder> folders = new Gee.ArrayList<Geary.ImapDB.Folder>();
foreach (Geary.FolderPath path in id_map.keys) {
Geary.ImapDB.Folder? folder = get_local_folder(path);
if (folder == null && id_map.has_key(path) && prop_map.has_key(path))
folder = create_local_folder(path, id_map.get(path), prop_map.get(path));
folders.add(folder);
}
return folders;
}
public async bool folder_exists_async(Geary.FolderPath path, Cancellable? cancellable = null)
throws Error {
check_open();
bool exists = false;
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
try {
int64 folder_id;
do_fetch_folder_id(cx, path, false, out folder_id, cancellable);
exists = (folder_id != Db.INVALID_ROWID);
} catch (EngineError err) {
// treat NOT_FOUND as non-exceptional situation
if (!(err is EngineError.NOT_FOUND))
throw err;
}
return Db.TransactionOutcome.DONE;
}, cancellable);
return exists;
}
public async Geary.ImapDB.Folder fetch_folder_async(Geary.FolderPath path,
Cancellable? cancellable = null) throws Error {
check_open();
// check references table first
Geary.ImapDB.Folder? folder = get_local_folder(path);
if (folder != null)
return folder;
int64 folder_id = Db.INVALID_ROWID;
Imap.FolderProperties? properties = null;
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
if (!do_fetch_folder_id(cx, path, false, out folder_id, cancellable))
return Db.TransactionOutcome.DONE;
if (folder_id == Db.INVALID_ROWID)
return Db.TransactionOutcome.DONE;
Db.Statement stmt = cx.prepare(
"SELECT last_seen_total, last_seen_status_total, uid_validity, uid_next, attributes "
+ "FROM FolderTable WHERE id=?");
stmt.bind_rowid(0, folder_id);
Db.Result results = stmt.exec(cancellable);
if (!results.finished) {
properties = new Imap.FolderProperties(results.int_for("last_seen_total"), 0,
new Imap.UIDValidity(results.int64_for("uid_validity")),
new Imap.UID(results.int64_for("uid_next")),
Geary.Imap.MailboxAttributes.deserialize(results.string_for("attributes")));
// due to legacy code, can't set last_seen_total to -1 to indicate that the folder
// hasn't been SELECT/EXAMINE'd yet, so the STATUS count should be used as the
// authoritative when the other is zero ... this is important when first creating a
// folder, as the STATUS is the count that is known first
properties.set_status_message_count(results.int_for("last_seen_status_total"),
(properties.select_examine_messages == 0));
}
return Db.TransactionOutcome.DONE;
}, cancellable);
if (folder_id == Db.INVALID_ROWID || properties == null)
throw new EngineError.NOT_FOUND("%s not found in local database", path.to_string());
return create_local_folder(path, folder_id, properties);
}
private Geary.ImapDB.Folder? get_local_folder(Geary.FolderPath path) {
FolderReference? folder_ref = folder_refs.get(path);
if (folder_ref == null)
return null;
ImapDB.Folder? folder = (Geary.ImapDB.Folder?) folder_ref.get_reference();
if (folder == null)
return null;
// use supplied FolderPath rather than one here; if it came from the server, it has
// a usable separator
if (path.get_root().default_separator != null)
folder.set_path(path);
return folder;
}
private Geary.ImapDB.Folder create_local_folder(Geary.FolderPath path, int64 folder_id,
Imap.FolderProperties properties) throws Error {
// return current if already created
ImapDB.Folder? folder = get_local_folder(path);
if (folder != null) {
// update properties
folder.set_properties(properties);
return folder;
}
// create folder
folder = new Geary.ImapDB.Folder(db, path, contact_store, account_information.email, 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);
return folder;
}
private void on_folder_reference_broken(Geary.SmartReference reference) {
FolderReference folder_ref = (FolderReference) reference;
// drop from folder references table, all cleaned up
folder_refs.unset(folder_ref.path);
}
public async Gee.MultiMap<Geary.Email, Geary.FolderPath?>? search_message_id_async(
Geary.RFC822.MessageID message_id, Geary.Email.Field requested_fields, bool partial_ok,
Gee.Collection<Geary.FolderPath?>? folder_blacklist, Cancellable? cancellable = null) throws Error {
Gee.HashMultiMap<Geary.Email, Geary.FolderPath?> messages
= new Gee.HashMultiMap<Geary.Email, Geary.FolderPath?>();
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
Db.Statement stmt = cx.prepare("SELECT id FROM MessageTable WHERE message_id = ? OR in_reply_to = ?");
stmt.bind_string(0, message_id.value);
stmt.bind_string(1, message_id.value);
Db.Result result = stmt.exec(cancellable);
while (!result.finished) {
int64 id = result.int64_at(0);
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
cx, id, requested_fields, cancellable);
// Ignore any messages that don't have the required fields.
if (partial_ok || row.fields.fulfills(requested_fields)) {
Geary.Email email = row.to_email(-1, new Geary.ImapDB.EmailIdentifier(id, null));
Geary.ImapDB.Folder.do_add_attachments(cx, email, id, cancellable);
Gee.Set<Geary.FolderPath>? folders = do_find_email_folders(cx, id, cancellable);
if (folders == null) {
if (folder_blacklist == null || !folder_blacklist.contains(null))
messages.set(email, null);
} else {
foreach (Geary.FolderPath path in folders) {
// If it's in a blacklisted folder, we don't report
// it at all.
if (folder_blacklist != null && folder_blacklist.contains(path)) {
messages.remove_all(email);
break;
} else {
messages.set(email, path);
}
}
}
}
result.next(cancellable);
}
return Db.TransactionOutcome.DONE;
}, cancellable);
return (messages.size == 0 ? null : messages);
}
public string prepare_search_query(string raw_query) {
// Two goals here:
// 1) append an * after every term so it becomes a prefix search
// (see <https://www.sqlite.org/fts3.html#section_3>), and
// 2) strip out common words/operators that might get interpreted as
// search operators.
// We ignore everything inside quotes to give the user a way to
// override our algorithm here. The idea is to offer one search query
// syntax for Geary that we can use locally and via IMAP, etc.
string[] words = raw_query.split_set(" \t\r\n:()*\\");
bool in_quote = false;
StringBuilder prepared_query = new StringBuilder();
foreach (string s in words) {
s = s.strip();
int quotes = Geary.String.count_char(s, '"');
if (!in_quote && quotes > 0) {
in_quote = true;
--quotes;
}
if (!in_quote) {
string lower = s.down();
if (lower == "" || lower == "and" || lower == "or" || lower == "not" || lower == "near"
|| lower.has_prefix("near/"))
continue;
if (s.has_prefix("-"))
s = s.substring(1);
if (s == "")
continue;
s = s + "*";
}
if (in_quote && quotes % 2 != 0)
in_quote = false;
prepared_query.append(s);
prepared_query.append(" ");
}
string prepared = prepared_query.str.strip();
if (in_quote)
prepared += "\"";
return prepared;
}
// Append each id in the collection to the StringBuilder, in a format
// suitable for use in an SQL statement IN (...) clause.
private void sql_append_ids(StringBuilder s, Gee.Collection<int64?> ids) {
bool first = true;
foreach (int64? id in ids) {
assert(id != null);
if (!first)
s.append(", ");
s.append(id.to_string());
first = false;
}
}
public async Gee.Collection<Geary.Email>? search_async(string prepared_query,
Geary.Email.Field requested_fields, bool partial_ok, Geary.FolderPath? email_id_folder_path,
int limit = 100, int offset = 0, Gee.Collection<Geary.FolderPath?>? folder_blacklist = null,
Gee.Collection<Geary.EmailIdentifier>? search_ids = null, Cancellable? cancellable = null) throws Error {
Gee.Collection<Geary.Email> search_results = new Gee.HashSet<Geary.Email>();
// TODO: support search_ids
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
string blacklisted_ids_sql = do_get_blacklisted_message_ids_sql(
folder_blacklist, cx, cancellable);
string sql = """
SELECT id
FROM MessageSearchTable
JOIN MessageTable USING (id)
WHERE MessageSearchTable MATCH ?
""";
if (blacklisted_ids_sql != "")
sql += " AND id NOT IN (%s)".printf(blacklisted_ids_sql);
sql += " ORDER BY internaldate_time_t DESC";
if (limit > 0)
sql += " LIMIT ? OFFSET ?";
Db.Statement stmt = cx.prepare(sql);
stmt.bind_string(0, prepared_query);
if (limit > 0) {
stmt.bind_int(1, limit);
stmt.bind_int(2, offset);
}
Db.Result result = stmt.exec(cancellable);
while (!result.finished) {
int64 id = result.int64_at(0);
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
cx, id, requested_fields, cancellable);
if (partial_ok || row.fields.fulfills(requested_fields)) {
Geary.Email email = row.to_email(-1,
new Geary.ImapDB.EmailIdentifier(id, email_id_folder_path));
Geary.ImapDB.Folder.do_add_attachments(cx, email, id, cancellable);
search_results.add(email);
}
result.next(cancellable);
}
return Db.TransactionOutcome.DONE;
}, cancellable);
return (search_results.size == 0 ? null : search_results);
}
public async Gee.Collection<string>? get_search_keywords_async(string prepared_query,
Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error {
Gee.Set<string> search_keywords = new Gee.HashSet<string>();
// Create a question mark for each ID.
string id_string = "";
for(int i = 0; i < ids.size; i++) {
id_string += "?";
if (i != ids.size - 1)
id_string += ", ";
}
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
Db.Statement stmt = cx.prepare("SELECT offsets(MessageSearchTable), * FROM MessageSearchTable " +
"WHERE MessageSearchTable MATCH ? AND id IN (%s)".printf(id_string));
// Bind query and IDs.
int i = 0;
stmt.bind_string(i++, prepared_query);
foreach(Geary.EmailIdentifier id in ids)
stmt.bind_rowid(i++, id.ordering);
Db.Result result = stmt.exec(cancellable);
while (!result.finished) {
// Build a list of search offsets.
string[] offset_array = result.string_at(0).split(" ");
Gee.ArrayList<SearchOffset> all_offsets = new Gee.ArrayList<SearchOffset>();
int j = 0;
while (true) {
all_offsets.add(new SearchOffset(offset_array[j:j+4]));
j += 4;
if (j >= offset_array.length)
break;
}
// Iterate over the offset list, scrape strings from the database, and push
// the results into our return set.
foreach(SearchOffset offset in all_offsets) {
string text = result.string_at(offset.column + 1);
search_keywords.add(text[offset.byte_offset : offset.byte_offset + offset.size].down());
}
result.next(cancellable);
}
return Db.TransactionOutcome.DONE;
}, cancellable);
return (search_keywords.size == 0 ? null : search_keywords);
}
public async Geary.Email fetch_email_async(Geary.EmailIdentifier email_id,
Geary.Email.Field required_fields, Cancellable? cancellable = null) throws Error {
if (!(email_id is Geary.ImapDB.EmailIdentifier))
throw new EngineError.BAD_PARAMETERS("email_id must be a Geary.ImapDB.EmailIdentifier");
Geary.Email? email = null;
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
// TODO: once we have a way of deleting messages, we won't be able
// to assume that a row id will point to the same email outside of
// transactions, because SQLite will reuse row ids.
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
cx, email_id.ordering, required_fields, cancellable);
if (!row.fields.fulfills(required_fields))
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(-1,
new Geary.ImapDB.EmailIdentifier(email_id.ordering, email_id.get_folder_path()));
Geary.ImapDB.Folder.do_add_attachments(cx, email, email_id.ordering, cancellable);
return Db.TransactionOutcome.DONE;
}, cancellable);
assert(email != null);
return email;
}
public async void update_contact_flags_async(Geary.Contact contact, Cancellable? cancellable)
throws Error{
check_open();
yield db.exec_transaction_async(Db.TransactionType.RW, (cx, cancellable) => {
Db.Statement update_stmt =
cx.prepare("UPDATE ContactTable SET flags=? WHERE email=?");
update_stmt.bind_string(0, contact.contact_flags.serialize());
update_stmt.bind_string(1, contact.email);
update_stmt.exec(cancellable);
return Db.TransactionOutcome.COMMIT;
}, cancellable);
}
public async int get_email_count_async(Cancellable? cancellable) throws Error {
check_open();
int count = 0;
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
count = do_get_email_count(cx, cancellable);
return Db.TransactionOutcome.SUCCESS;
}, cancellable);
return count;
}
private async void populate_search_table_async(Cancellable? cancellable) {
debug("Populating search table");
try {
int total = yield get_email_count_async(cancellable);
search_index_monitor.set_interval(0, total);
search_index_monitor.notify_start();
while (!yield populate_search_table_batch_async(100, cancellable))
;
} catch (Error e) {
debug("Error populating search table: %s", e.message);
}
if (search_index_monitor.is_in_progress)
search_index_monitor.notify_finish();
debug("Done populating search table");
}
private async bool populate_search_table_batch_async(int limit = 100,
Cancellable? cancellable) throws Error {
int count = 0;
yield db.exec_transaction_async(Db.TransactionType.RW, (cx, cancellable) => {
Db.Statement stmt = cx.prepare("""
SELECT id
FROM MessageTable
WHERE id NOT IN (
SELECT id
FROM MessageSearchTable
)
LIMIT ?
""");
stmt.bind_int(0, limit);
Db.Result result = stmt.exec(cancellable);
while (!result.finished) {
int64 id = result.rowid_at(0);
try {
Geary.Email.Field search_fields = Geary.Email.REQUIRED_FOR_MESSAGE |
Geary.Email.Field.ORIGINATORS | Geary.Email.Field.RECEIVERS |
Geary.Email.Field.SUBJECT;
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
cx, id, search_fields, cancellable);
Geary.Email email = row.to_email(-1, new Geary.ImapDB.EmailIdentifier(id, null));
Geary.ImapDB.Folder.do_add_attachments(cx, email, id, cancellable);
Geary.ImapDB.Folder.do_add_email_to_search_table(cx, id, email, cancellable);
} catch (Error e) {
// This is a somewhat serious issue since we rely on
// there always being a row in the search table for
// every message.
warning("Error adding message %lld to the search table: %s", id, e.message);
}
++count;
result.next(cancellable);
}
return Db.TransactionOutcome.DONE;
}, cancellable);
search_index_monitor.increment(count);
return (count < limit);
}
//
// Transaction helper methods
//
// If the FolderPath has no parent, returns true and folder_id will be set to Db.INVALID_ROWID.
// If cannot create path or there is a logical problem traversing it, returns false with folder_id
// set to Db.INVALID_ROWID.
private bool do_fetch_folder_id(Db.Connection cx, Geary.FolderPath path, bool create, out int64 folder_id,
Cancellable? cancellable) throws Error {
check_open();
int length = path.get_path_length();
if (length < 0)
throw new EngineError.BAD_PARAMETERS("Invalid path %s", path.to_string());
folder_id = Db.INVALID_ROWID;
int64 parent_id = Db.INVALID_ROWID;
// walk the folder tree to the final node (which is at length - 1 - 1)
for (int ctr = 0; ctr < length; ctr++) {
string basename = path.get_folder_at(ctr).basename;
Db.Statement stmt;
if (parent_id != Db.INVALID_ROWID) {
stmt = cx.prepare("SELECT id FROM FolderTable WHERE parent_id=? AND name=?");
stmt.bind_rowid(0, parent_id);
stmt.bind_string(1, basename);
} else {
stmt = cx.prepare("SELECT id FROM FolderTable WHERE parent_id IS NULL AND name=?");
stmt.bind_string(0, basename);
}
int64 id = Db.INVALID_ROWID;
Db.Result result = stmt.exec(cancellable);
if (!result.finished) {
id = result.rowid_at(0);
} else if (!create) {
return false;
} else {
// not found, create it
Db.Statement create_stmt = cx.prepare(
"INSERT INTO FolderTable (name, parent_id) VALUES (?, ?)");
create_stmt.bind_string(0, basename);
create_stmt.bind_rowid(1, parent_id);
id = create_stmt.exec_insert(cancellable);
}
// watch for path loops, real bad if it happens ... could be more thorough here, but at
// least one level of checking is better than none
if (id == parent_id) {
warning("Loop found in database: parent of %s is %s in FolderTable",
parent_id.to_string(), id.to_string());
return false;
}
parent_id = id;
}
// parent_id is now the folder being searched for
folder_id = parent_id;
return true;
}
// See do_fetch_folder_id() for return semantics.
private bool do_fetch_parent_id(Db.Connection cx, Geary.FolderPath path, bool create, out int64 parent_id,
Cancellable? cancellable = null) throws Error {
if (path.is_root()) {
parent_id = Db.INVALID_ROWID;
return true;
}
return do_fetch_folder_id(cx, path.get_parent(), create, out parent_id, cancellable);
}
// Turn the collection of folder paths into actual folder ids. As a
// special case, if "folderless" or orphan emails are to be blacklisted,
// set the out bool to true.
private Gee.Collection<int64?> do_get_blacklisted_folder_ids(Gee.Collection<Geary.FolderPath?>? folder_blacklist,
Db.Connection cx, out bool blacklist_folderless, Cancellable? cancellable) throws Error {
blacklist_folderless = false;
Gee.ArrayList<int64?> ids = new Gee.ArrayList<int64?>();
if (folder_blacklist != null) {
foreach (Geary.FolderPath? folder_path in folder_blacklist) {
if (folder_path == null) {
blacklist_folderless = true;
} else {
int64 id;
do_fetch_folder_id(cx, folder_path, true, out id, cancellable);
if (id != Db.INVALID_ROWID)
ids.add(id);
}
}
}
return ids;
}
// Return a parameterless SQL statement that selects any message ids that
// are in a blacklisted folder. This is used as a sub-select for the
// search query to omit results from blacklisted folders.
private string do_get_blacklisted_message_ids_sql(Gee.Collection<Geary.FolderPath?>? folder_blacklist,
Db.Connection cx, Cancellable? cancellable) throws Error {
bool blacklist_folderless;
Gee.Collection<int64?> blacklisted_ids = do_get_blacklisted_folder_ids(
folder_blacklist, cx, out blacklist_folderless, cancellable);
StringBuilder sql = new StringBuilder();
if (blacklisted_ids.size > 0 || blacklist_folderless) {
if (blacklist_folderless) {
// We select out of the MessageTable and join on the location
// table so we can recognize emails that aren't in any folders.
// This is slightly more complicated than the case below, where
// we can just select directly out of the location table.
sql.append("""
SELECT m.id
FROM MessageTable m
LEFT JOIN MessageLocationTable l ON l.message_id = m.id
WHERE folder_id IS NULL
""");
if (blacklisted_ids.size > 0) {
sql.append(" OR folder_id IN (");
sql_append_ids(sql, blacklisted_ids);
sql.append(")");
}
} else {
sql.append("""
SELECT message_id
FROM MessageLocationTable
WHERE folder_id IN (
""");
sql_append_ids(sql, blacklisted_ids);
sql.append(")");
}
}
return sql.str;
}
// For a message row id, return a set of all folders it's in, or null if
// it's not in any folders.
private Gee.Set<Geary.FolderPath>? do_find_email_folders(Db.Connection cx, int64 message_id,
Cancellable? cancellable) throws Error {
Db.Statement stmt = cx.prepare("SELECT folder_id FROM MessageLocationTable WHERE message_id=?");
stmt.bind_int64(0, message_id);
Db.Result result = stmt.exec(cancellable);
if (result.finished)
return null;
Gee.HashSet<Geary.FolderPath> folder_paths = new Gee.HashSet<Geary.FolderPath>();
while (!result.finished) {
int64 folder_id = result.int64_at(0);
Geary.FolderPath? path = do_find_folder_path(cx, folder_id, cancellable);
if (path != null)
folder_paths.add(path);
result.next(cancellable);
}
return (folder_paths.size == 0 ? null : folder_paths);
}
// For a folder row id, return the folder path (constructed with default
// separator and case sensitivity) of that folder, or null in the event
// it's not found.
private Geary.FolderPath? do_find_folder_path(Db.Connection cx, int64 folder_id,
Cancellable? cancellable) throws Error {
Db.Statement stmt = cx.prepare("SELECT parent_id, name FROM FolderTable WHERE id=?");
stmt.bind_int64(0, folder_id);
Db.Result result = stmt.exec(cancellable);
if (result.finished)
return null;
int64 parent_id = result.int64_at(0);
string name = result.string_at(1);
// Here too, one level of loop detection is better than nothing.
if (folder_id == parent_id) {
warning("Loop found in database: parent of %s is %s in FolderTable",
folder_id.to_string(), parent_id.to_string());
return null;
}
if (parent_id <= 0)
return new Geary.FolderRoot(name, null, Geary.Imap.Folder.CASE_SENSITIVE);
Geary.FolderPath? parent_path = do_find_folder_path(cx, parent_id, cancellable);
return (parent_path == null ? null : parent_path.get_child(name));
}
// For SELECT/EXAMINE responses, not STATUS responses
private void do_update_last_seen_select_examine_total(Db.Connection cx, int64 parent_id, string name, int total,
Cancellable? cancellable) throws Error {
do_update_total(cx, parent_id, name, "last_seen_total", total, cancellable);
}
// For STATUS responses, not SELECT/EXAMINE responses
private void do_update_last_seen_status_total(Db.Connection cx, int64 parent_id, string name,
int total, Cancellable? cancellable) throws Error {
do_update_total(cx, parent_id, name, "last_seen_status_total", total, cancellable);
}
private void do_update_total(Db.Connection cx, int64 parent_id, string name, string colname,
int total, Cancellable? cancellable) throws Error {
Db.Statement stmt;
if (parent_id != Db.INVALID_ROWID) {
stmt = cx.prepare(
"UPDATE FolderTable SET %s=? WHERE parent_id=? AND name=?".printf(colname));
stmt.bind_int(0, Numeric.int_floor(total, 0));
stmt.bind_rowid(1, parent_id);
stmt.bind_string(2, name);
} else {
stmt = cx.prepare(
"UPDATE FolderTable SET %s=? WHERE parent_id IS NULL AND name=?".printf(colname));
stmt.bind_int(0, Numeric.int_floor(total, 0));
stmt.bind_string(1, name);
}
stmt.exec(cancellable);
}
private int do_get_email_count(Db.Connection cx, Cancellable? cancellable)
throws Error {
Db.Statement stmt = cx.prepare(
"SELECT COUNT(*) FROM MessageTable");
Db.Result results = stmt.exec(cancellable);
if (results.finished)
return 0;
return results.int_at(0);
}
}