diff --git a/briar-api/src/org/briarproject/api/clients/ClientHelper.java b/briar-api/src/org/briarproject/api/clients/ClientHelper.java
index 06b5c7c5cb3bfea4ec8d747e633c883e8cb1c7bc..ea479a7f6e6a89ec07382e49724d9a588b40a1cb 100644
--- a/briar-api/src/org/briarproject/api/clients/ClientHelper.java
+++ b/briar-api/src/org/briarproject/api/clients/ClientHelper.java
@@ -27,12 +27,6 @@ public interface ClientHelper {
 	Message createMessage(GroupId g, long timestamp, BdfList body)
 			throws FormatException;
 
-	BdfDictionary getMessageAsDictionary(MessageId m) throws DbException,
-			FormatException;
-
-	BdfDictionary getMessageAsDictionary(Transaction txn, MessageId m)
-			throws DbException, FormatException;
-
 	BdfList getMessageAsList(MessageId m) throws DbException, FormatException;
 
 	BdfList getMessageAsList(Transaction txn, MessageId m) throws DbException,
@@ -50,12 +44,19 @@ public interface ClientHelper {
 	BdfDictionary getMessageMetadataAsDictionary(Transaction txn, MessageId m)
 			throws DbException, FormatException;
 
-	Map<MessageId, BdfDictionary> getMessageMetatataAsDictionary(GroupId g)
+	Map<MessageId, BdfDictionary> getMessageMetadataAsDictionary(GroupId g)
 			throws DbException, FormatException;
 
 	Map<MessageId, BdfDictionary> getMessageMetadataAsDictionary(
 			Transaction txn, GroupId g) throws DbException, FormatException;
 
+	Map<MessageId, BdfDictionary> getMessageMetadataAsDictionary(GroupId g,
+			BdfDictionary query) throws DbException, FormatException;
+
+	Map<MessageId, BdfDictionary> getMessageMetadataAsDictionary(
+			Transaction txn, GroupId g, BdfDictionary query) throws DbException,
+			FormatException;
+
 	void mergeGroupMetadata(GroupId g, BdfDictionary metadata)
 			throws DbException, FormatException;
 
diff --git a/briar-api/src/org/briarproject/api/db/DatabaseComponent.java b/briar-api/src/org/briarproject/api/db/DatabaseComponent.java
index eca709bc3af17985b32dab4f5c03ac03d80c9889..e145d9e8442fd022e6ee1424429950231a658cf0 100644
--- a/briar-api/src/org/briarproject/api/db/DatabaseComponent.java
+++ b/briar-api/src/org/briarproject/api/db/DatabaseComponent.java
@@ -249,6 +249,16 @@ public interface DatabaseComponent {
 	Map<MessageId, Metadata> getMessageMetadata(Transaction txn, GroupId g)
 			throws DbException;
 
+	/**
+	 * Returns the metadata for any messages in the given group with metadata
+	 * that matches all entries in the given query. If the query is empty, the
+	 * metadata for all messages is returned.
+	 * <p/>
+	 * Read-only.
+	 */
+	Map<MessageId, Metadata> getMessageMetadata(Transaction txn, GroupId g,
+			Metadata query) throws DbException;
+
 	/**
 	 * Returns the metadata for the given message.
 	 * <p/>
diff --git a/briar-core/src/org/briarproject/clients/ClientHelperImpl.java b/briar-core/src/org/briarproject/clients/ClientHelperImpl.java
index 5c64dc98feba454d705a5703587f315c2d39bae2..61ac38c40dd77cf74194101bcb900f274f0f38c6 100644
--- a/briar-core/src/org/briarproject/clients/ClientHelperImpl.java
+++ b/briar-core/src/org/briarproject/clients/ClientHelperImpl.java
@@ -85,29 +85,6 @@ class ClientHelperImpl implements ClientHelper {
 		return messageFactory.createMessage(g, timestamp, toByteArray(body));
 	}
 
-	@Override
-	public BdfDictionary getMessageAsDictionary(MessageId m) throws DbException,
-			FormatException {
-		BdfDictionary dictionary;
-		Transaction txn = db.startTransaction(true);
-		try {
-			dictionary = getMessageAsDictionary(txn, m);
-			txn.setComplete();
-		} finally {
-			db.endTransaction(txn);
-		}
-		return dictionary;
-	}
-
-	@Override
-	public BdfDictionary getMessageAsDictionary(Transaction txn, MessageId m)
-			throws DbException, FormatException {
-		byte[] raw = db.getRawMessage(txn, m);
-		if (raw == null) return null;
-		return toDictionary(raw, MESSAGE_HEADER_LENGTH,
-				raw.length - MESSAGE_HEADER_LENGTH);
-	}
-
 	@Override
 	public BdfList getMessageAsList(MessageId m) throws DbException,
 			FormatException {
@@ -174,7 +151,7 @@ class ClientHelperImpl implements ClientHelper {
 	}
 
 	@Override
-	public Map<MessageId, BdfDictionary> getMessageMetatataAsDictionary(
+	public Map<MessageId, BdfDictionary> getMessageMetadataAsDictionary(
 			GroupId g) throws DbException, FormatException {
 		Map<MessageId, BdfDictionary> map;
 		Transaction txn = db.startTransaction(true);
@@ -198,6 +175,34 @@ class ClientHelperImpl implements ClientHelper {
 		return Collections.unmodifiableMap(parsed);
 	}
 
+	@Override
+	public Map<MessageId, BdfDictionary> getMessageMetadataAsDictionary(
+			GroupId g, BdfDictionary query) throws DbException,
+			FormatException {
+		Map<MessageId, BdfDictionary> map;
+		Transaction txn = db.startTransaction(true);
+		try {
+			map = getMessageMetadataAsDictionary(txn, g, query);
+			txn.setComplete();
+		} finally {
+			db.endTransaction(txn);
+		}
+		return map;
+	}
+
+	@Override
+	public Map<MessageId, BdfDictionary> getMessageMetadataAsDictionary(
+			Transaction txn, GroupId g, BdfDictionary query) throws DbException,
+			FormatException {
+		Metadata metadata = metadataEncoder.encode(query);
+		Map<MessageId, Metadata> raw = db.getMessageMetadata(txn, g, metadata);
+		Map<MessageId, BdfDictionary> parsed =
+				new HashMap<MessageId, BdfDictionary>(raw.size());
+		for (Entry<MessageId, Metadata> e : raw.entrySet())
+			parsed.put(e.getKey(), metadataParser.parse(e.getValue()));
+		return Collections.unmodifiableMap(parsed);
+	}
+
 	@Override
 	public void mergeGroupMetadata(GroupId g, BdfDictionary metadata)
 			throws DbException, FormatException {
diff --git a/briar-core/src/org/briarproject/db/Database.java b/briar-core/src/org/briarproject/db/Database.java
index 981da4f285fd5e97f83926d40fe25fb5ed106d53..6589fbc81477147535e4af61402ae8944b2d8963 100644
--- a/briar-core/src/org/briarproject/db/Database.java
+++ b/briar-core/src/org/briarproject/db/Database.java
@@ -273,6 +273,16 @@ interface Database<T> {
 	 */
 	Collection<MessageId> getMessageIds(T txn, GroupId g) throws DbException;
 
+	/**
+	 * Returns the IDs of any messages in the given group with metadata
+	 * matching all entries in the given query. If the query is empty, the IDs
+	 * of all messages are returned.
+	 * <p/>
+	 * Read-only.
+	 */
+	Collection<MessageId> getMessageIds(T txn, GroupId g, Metadata query)
+			throws DbException;
+
 	/**
 	 * Returns the metadata for all messages in the given group.
 	 * <p/>
@@ -281,6 +291,16 @@ interface Database<T> {
 	Map<MessageId, Metadata> getMessageMetadata(T txn, GroupId g)
 			throws DbException;
 
+	/**
+	 * Returns the metadata for any messages in the given group with metadata
+	 * matching all entries in the given query. If the query is empty, the
+	 * metadata for all messages is returned.
+	 * <p/>
+	 * Read-only.
+	 */
+	Map<MessageId, Metadata> getMessageMetadata(T txn, GroupId g,
+			Metadata query) throws DbException;
+
 	/**
 	 * Returns the metadata for the given message.
 	 * <p/>
diff --git a/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java b/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java
index 45a9c4cbf41c319125aa2530e8e5b7210031f03b..37c1f08849656f65599113db5264d502f1171928 100644
--- a/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java
+++ b/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java
@@ -427,6 +427,14 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		return db.getMessageMetadata(txn, g);
 	}
 
+	public Map<MessageId, Metadata> getMessageMetadata(Transaction transaction,
+			GroupId g, Metadata query) throws DbException {
+		T txn = unbox(transaction);
+		if (!db.containsGroup(txn, g))
+			throw new NoSuchGroupException();
+		return db.getMessageMetadata(txn, g, query);
+	}
+
 	public Metadata getMessageMetadata(Transaction transaction, MessageId m)
 			throws DbException {
 		T txn = unbox(transaction);
diff --git a/briar-core/src/org/briarproject/db/JdbcDatabase.java b/briar-core/src/org/briarproject/db/JdbcDatabase.java
index d46221934ba9dc18876bc031e4e9324efa635edf..e3e026f221b757029d6542927878bc8ed52e213c 100644
--- a/briar-core/src/org/briarproject/db/JdbcDatabase.java
+++ b/briar-core/src/org/briarproject/db/JdbcDatabase.java
@@ -36,10 +36,12 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Set;
 import java.util.concurrent.locks.Condition;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
@@ -1133,6 +1135,44 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public Collection<MessageId> getMessageIds(Connection txn, GroupId g,
+			Metadata query) throws DbException {
+		// If there are no query terms, return all messages
+		if (query.isEmpty()) return getMessageIds(txn, g);
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Retrieve the message IDs for each query term and intersect
+			Set<MessageId> intersection = null;
+			String sql = "SELECT m.messageId"
+					+ " FROM messages AS m"
+					+ " JOIN messageMetadata AS md"
+					+ " ON m.messageId = md.messageId"
+					+ " WHERE groupId = ? AND key = ? AND value = ?";
+			for (Entry<String, byte[]> e : query.entrySet()) {
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, g.getBytes());
+				ps.setString(2, e.getKey());
+				ps.setBytes(3, e.getValue());
+				rs = ps.executeQuery();
+				Set<MessageId> ids = new HashSet<MessageId>();
+				while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+				rs.close();
+				ps.close();
+				if (intersection == null) intersection = ids;
+				else intersection.retainAll(ids);
+				// Return early if there are no matches
+				if (intersection.isEmpty()) return Collections.emptySet();
+			}
+			if (intersection == null) throw new IllegalStateException();
+			return Collections.unmodifiableSet(intersection);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
 			GroupId g) throws DbException {
 		PreparedStatement ps = null;
@@ -1169,6 +1209,18 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g, Metadata query) throws DbException {
+		// Retrieve the matching message IDs
+		Collection<MessageId> matches = getMessageIds(txn, g, query);
+		if (matches.isEmpty()) return Collections.emptyMap();
+		// Retrieve the metadata for each match
+		Map<MessageId, Metadata> all = new HashMap<MessageId, Metadata>(
+				matches.size());
+		for (MessageId m : matches) all.put(m, getMessageMetadata(txn, m));
+		return Collections.unmodifiableMap(all);
+	}
+
 	public Metadata getGroupMetadata(Connection txn, GroupId g)
 			throws DbException {
 		return getMetadata(txn, g.getBytes(), "groupMetadata", "groupId");
diff --git a/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java b/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java
index 97b08d900f5936118a5ea410019bcbec0a9976a9..ff6f7a918cc75941551be6c222aa64bade8358c5 100644
--- a/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java
+++ b/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java
@@ -115,8 +115,8 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 
 	private DatabaseComponent createDatabaseComponent(Database<Object> database,
 			EventBus eventBus, ShutdownManager shutdown) {
-		return new DatabaseComponentImpl<Object>(database, Object.class,
-				eventBus, shutdown);
+		return new DatabaseComponentImpl<>(database, Object.class, eventBus,
+				shutdown);
 	}
 
 	@Test
@@ -559,11 +559,11 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 		final EventBus eventBus = context.mock(EventBus.class);
 		context.checking(new Expectations() {{
 			// Check whether the group is in the DB (which it's not)
-			exactly(7).of(database).startTransaction();
+			exactly(9).of(database).startTransaction();
 			will(returnValue(txn));
-			exactly(7).of(database).containsGroup(txn, groupId);
+			exactly(9).of(database).containsGroup(txn, groupId);
 			will(returnValue(false));
-			exactly(7).of(database).abortTransaction(txn);
+			exactly(9).of(database).abortTransaction(txn);
 			// This is needed for getMessageStatus(), isVisibleToContact(), and
 			// setVisibleToContact() to proceed
 			exactly(3).of(database).containsContact(txn, contactId);
@@ -592,6 +592,26 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			db.endTransaction(transaction);
 		}
 
+		transaction = db.startTransaction(false);
+		try {
+			db.getMessageMetadata(transaction, groupId);
+			fail();
+		} catch (NoSuchGroupException expected) {
+			// Expected
+		} finally {
+			db.endTransaction(transaction);
+		}
+
+		transaction = db.startTransaction(false);
+		try {
+			db.getMessageMetadata(transaction, groupId, new Metadata());
+			fail();
+		} catch (NoSuchGroupException expected) {
+			// Expected
+		} finally {
+			db.endTransaction(transaction);
+		}
+
 		transaction = db.startTransaction(false);
 		try {
 			db.getMessageStatus(transaction, contactId, groupId);
diff --git a/briar-tests/src/org/briarproject/db/H2DatabaseTest.java b/briar-tests/src/org/briarproject/db/H2DatabaseTest.java
index 24ffe530807fd9a145c7bfd28ffb59f89e167124..d967b815973b6bb6246dc23d6075b5621522e7b7 100644
--- a/briar-tests/src/org/briarproject/db/H2DatabaseTest.java
+++ b/briar-tests/src/org/briarproject/db/H2DatabaseTest.java
@@ -595,7 +595,7 @@ public class H2DatabaseTest extends BriarTestCase {
 	@Test
 	public void testMultipleGroupChanges() throws Exception {
 		// Create some groups
-		List<Group> groups = new ArrayList<Group>();
+		List<Group> groups = new ArrayList<>();
 		for (int i = 0; i < 100; i++) {
 			GroupId id = new GroupId(TestUtils.getRandomId());
 			ClientId clientId = new ClientId(TestUtils.getRandomId());
@@ -803,7 +803,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		assertEquals(0, db.countOfferedMessages(txn, contactId));
 
 		// Add some offered messages and count them
-		List<MessageId> ids = new ArrayList<MessageId>();
+		List<MessageId> ids = new ArrayList<>();
 		for (int i = 0; i < 10; i++) {
 			MessageId m = new MessageId(TestUtils.getRandomId());
 			db.addOfferedMessage(txn, contactId, m);
@@ -930,6 +930,110 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.close();
 	}
 
+	@Test
+	public void testMetadataQueries() throws Exception {
+		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		Message message1 = new Message(messageId1, groupId, timestamp, raw);
+
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a group and two messages
+		db.addGroup(txn, group);
+		db.addMessage(txn, message, VALID, true);
+		db.addMessage(txn, message1, VALID, true);
+
+		// Attach some metadata to the messages
+		Metadata metadata = new Metadata();
+		metadata.put("foo", new byte[]{'b', 'a', 'r'});
+		metadata.put("baz", new byte[]{'b', 'a', 'm'});
+		db.mergeMessageMetadata(txn, messageId, metadata);
+		Metadata metadata1 = new Metadata();
+		metadata1.put("foo", new byte[]{'q', 'u', 'x'});
+		db.mergeMessageMetadata(txn, messageId1, metadata1);
+
+		// Retrieve all the metadata for the group
+		Map<MessageId, Metadata> all = db.getMessageMetadata(txn, groupId);
+		assertEquals(2, all.size());
+		assertTrue(all.containsKey(messageId));
+		assertTrue(all.containsKey(messageId1));
+		Metadata retrieved = all.get(messageId);
+		assertEquals(2, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata.get("foo"), retrieved.get("foo"));
+		assertTrue(retrieved.containsKey("baz"));
+		assertArrayEquals(metadata.get("baz"), retrieved.get("baz"));
+		retrieved = all.get(messageId1);
+		assertEquals(1, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata1.get("foo"), retrieved.get("foo"));
+
+		// Query the metadata with an empty query
+		Metadata query = new Metadata();
+		all = db.getMessageMetadata(txn, groupId, query);
+		assertEquals(2, all.size());
+		assertTrue(all.containsKey(messageId));
+		assertTrue(all.containsKey(messageId1));
+		retrieved = all.get(messageId);
+		assertEquals(2, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata.get("foo"), retrieved.get("foo"));
+		assertTrue(retrieved.containsKey("baz"));
+		assertArrayEquals(metadata.get("baz"), retrieved.get("baz"));
+		retrieved = all.get(messageId1);
+		assertEquals(1, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata1.get("foo"), retrieved.get("foo"));
+
+		// Use a single-term query that matches the first message
+		query = new Metadata();
+		query.put("foo", metadata.get("foo"));
+		all = db.getMessageMetadata(txn, groupId, query);
+		assertEquals(1, all.size());
+		assertTrue(all.containsKey(messageId));
+		retrieved = all.get(messageId);
+		assertEquals(2, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata.get("foo"), retrieved.get("foo"));
+		assertTrue(retrieved.containsKey("baz"));
+		assertArrayEquals(metadata.get("baz"), retrieved.get("baz"));
+
+		// Use a single-term query that matches the second message
+		query = new Metadata();
+		query.put("foo", metadata1.get("foo"));
+		all = db.getMessageMetadata(txn, groupId, query);
+		assertEquals(1, all.size());
+		assertTrue(all.containsKey(messageId1));
+		retrieved = all.get(messageId1);
+		assertEquals(1, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata1.get("foo"), retrieved.get("foo"));
+
+		// Use a multi-term query that matches the first message
+		query = new Metadata();
+		query.put("foo", metadata.get("foo"));
+		query.put("baz", metadata.get("baz"));
+		all = db.getMessageMetadata(txn, groupId, query);
+		assertEquals(1, all.size());
+		assertTrue(all.containsKey(messageId));
+		retrieved = all.get(messageId);
+		assertEquals(2, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata.get("foo"), retrieved.get("foo"));
+		assertTrue(retrieved.containsKey("baz"));
+		assertArrayEquals(metadata.get("baz"), retrieved.get("baz"));
+
+		// Use a multi-term query that doesn't match any messages
+		query = new Metadata();
+		query.put("foo", metadata1.get("foo"));
+		query.put("baz", metadata.get("baz"));
+		all = db.getMessageMetadata(txn, groupId, query);
+		assertTrue(all.isEmpty());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
 	@Test
 	public void testGetMessageStatus() throws Exception {
 		Database<Connection> db = open(false);