diff --git a/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java b/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java
index 18c5da1c691ef626deb5634194cde763a1730b1f..f02b9b635cc3a40cd655074f1e3c8e8ff1bdb447 100644
--- a/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java
+++ b/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java
@@ -45,22 +45,4 @@ public interface IntroductionManager {
 	Collection<IntroductionMessage> getIntroductionMessages(ContactId contactId)
 			throws DbException;
 
-	/** Marks an introduction message as read or unread. */
-	void setReadFlag(MessageId m, boolean read) throws DbException;
-
-
-	/** Get the session state for the given session ID */
-	BdfDictionary getSessionState(Transaction txn, GroupId groupId,
-			byte[] sessionId) throws DbException, FormatException;
-
-	/** Gets the group used for introductions with Contact c */
-	Group getIntroductionGroup(Contact c);
-
-	/** Get the local group used to store session states */
-	Group getLocalGroup();
-
-	/** Send an introduction message */
-	void sendMessage(Transaction txn, BdfDictionary message)
-			throws DbException, FormatException;
-
 }
diff --git a/briar-core/src/org/briarproject/introduction/IntroduceeManager.java b/briar-core/src/org/briarproject/introduction/IntroduceeManager.java
index 64af33b44d59cd2f8ff89d194cefb9fb7e97ae7d..b25f299e024870824fbbcd2f8329ae530b1dd085 100644
--- a/briar-core/src/org/briarproject/introduction/IntroduceeManager.java
+++ b/briar-core/src/org/briarproject/introduction/IntroduceeManager.java
@@ -24,11 +24,8 @@ import org.briarproject.api.event.IntroductionSucceededEvent;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.identity.AuthorId;
-import org.briarproject.api.introduction.IntroductionManager;
-import org.briarproject.api.introduction.SessionId;
 import org.briarproject.api.properties.TransportProperties;
 import org.briarproject.api.properties.TransportPropertyManager;
-import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
@@ -40,6 +37,8 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.logging.Logger;
 
+import javax.inject.Inject;
+
 import static java.util.logging.Level.WARNING;
 import static org.briarproject.api.introduction.IntroduceeProtocolState.AWAIT_REQUEST;
 import static org.briarproject.api.introduction.IntroductionConstants.ACCEPT;
@@ -76,33 +75,36 @@ import static org.briarproject.api.introduction.IntroductionConstants.TYPE_RESPO
 
 class IntroduceeManager {
 
-
 	private static final Logger LOG =
 			Logger.getLogger(IntroduceeManager.class.getName());
 
+	private final MessageSender messageSender;
 	private final DatabaseComponent db;
-	private final IntroductionManager introductionManager;
 	private final ClientHelper clientHelper;
 	private final Clock clock;
 	private final CryptoComponent cryptoComponent;
 	private final TransportPropertyManager transportPropertyManager;
 	private final AuthorFactory authorFactory;
 	private final ContactManager contactManager;
+	private final IntroductionGroupFactory introductionGroupFactory;
 
-	IntroduceeManager(DatabaseComponent db,
-			IntroductionManager introductionManager, ClientHelper clientHelper,
-			Clock clock, CryptoComponent cryptoComponent,
+	@Inject
+	IntroduceeManager(MessageSender messageSender, DatabaseComponent db,
+			ClientHelper clientHelper, Clock clock,
+			CryptoComponent cryptoComponent,
 			TransportPropertyManager transportPropertyManager,
-			AuthorFactory authorFactory, ContactManager contactManager) {
+			AuthorFactory authorFactory, ContactManager contactManager,
+			IntroductionGroupFactory introductionGroupFactory) {
 
+		this.messageSender = messageSender;
 		this.db = db;
-		this.introductionManager = introductionManager;
 		this.clientHelper = clientHelper;
 		this.clock = clock;
 		this.cryptoComponent = cryptoComponent;
 		this.transportPropertyManager = transportPropertyManager;
 		this.authorFactory = authorFactory;
 		this.contactManager = contactManager;
+		this.introductionGroupFactory = introductionGroupFactory;
 	}
 
 	public BdfDictionary initialize(Transaction txn, GroupId groupId,
@@ -113,9 +115,9 @@ class IntroduceeManager {
 		Bytes salt = new Bytes(new byte[64]);
 		cryptoComponent.getSecureRandom().nextBytes(salt.getBytes());
 
-		Message localMsg = clientHelper
-				.createMessage(introductionManager.getLocalGroup().getId(), now,
-						BdfList.of(salt));
+		Message localMsg = clientHelper.createMessage(
+				introductionGroupFactory.createLocalGroup().getId(), now,
+				BdfList.of(salt));
 		MessageId storageId = localMsg.getId();
 
 		// find out who is introducing us
@@ -133,7 +135,7 @@ class IntroduceeManager {
 		d.put(INTRODUCER, introducer.getAuthor().getName());
 		d.put(CONTACT_ID_1, introducer.getId().getInt());
 		d.put(LOCAL_AUTHOR_ID, introducer.getLocalAuthorId().getBytes());
-		d.put(NOT_OUR_RESPONSE, new byte[0]);
+		d.put(NOT_OUR_RESPONSE, storageId);
 		d.put(ANSWERED, false);
 
 		// check if the contact we are introduced to does already exist
@@ -147,7 +149,7 @@ class IntroduceeManager {
 
 		// save local state to database
 		clientHelper.addLocalMessage(txn, localMsg,
-				introductionManager.getClientId(), d, false);
+				IntroductionManagerImpl.CLIENT_ID, d, false);
 
 		return d;
 	}
@@ -159,16 +161,10 @@ class IntroduceeManager {
 		processStateUpdate(txn, engine.onMessageReceived(state, message));
 	}
 
-	public void acceptIntroduction(Transaction txn, final ContactId contactId,
-			final SessionId sessionId, final long timestamp)
+	public void acceptIntroduction(Transaction txn, BdfDictionary state,
+			final long timestamp)
 			throws DbException, FormatException {
 
-		Contact c = db.getContact(txn, contactId);
-		Group g = introductionManager.getIntroductionGroup(c);
-
-		BdfDictionary state = introductionManager
-				.getSessionState(txn, g.getId(), sessionId.getBytes());
-
 		// get data to connect and derive a shared secret later
 		long now = clock.currentTimeMillis();
 		KeyPair keyPair = cryptoComponent.generateAgreementKeyPair();
@@ -195,16 +191,10 @@ class IntroduceeManager {
 		processStateUpdate(txn, engine.onLocalAction(state, localAction));
 	}
 
-	public void declineIntroduction(Transaction txn, final ContactId contactId,
-			final SessionId sessionId, final long timestamp)
+	public void declineIntroduction(Transaction txn, BdfDictionary state,
+			final long timestamp)
 			throws DbException, FormatException {
 
-		Contact c = db.getContact(txn, contactId);
-		Group g = introductionManager.getIntroductionGroup(c);
-
-		BdfDictionary state = introductionManager
-				.getSessionState(txn, g.getId(), sessionId.getBytes());
-
 		// update session state
 		state.put(ACCEPT, false);
 
@@ -233,7 +223,7 @@ class IntroduceeManager {
 
 		// send messages
 		for (BdfDictionary d : result.toSend) {
-			introductionManager.sendMessage(txn, d);
+			messageSender.sendMessage(txn, d);
 		}
 
 		// broadcast events
diff --git a/briar-core/src/org/briarproject/introduction/IntroducerManager.java b/briar-core/src/org/briarproject/introduction/IntroducerManager.java
index 93481367df01a89508d541d4ca6be4b21072288d..891c4b7b599e3eb2bf679a8eeadd49c237c8d98a 100644
--- a/briar-core/src/org/briarproject/introduction/IntroducerManager.java
+++ b/briar-core/src/org/briarproject/introduction/IntroducerManager.java
@@ -10,7 +10,6 @@ import org.briarproject.api.data.BdfList;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Transaction;
 import org.briarproject.api.event.Event;
-import org.briarproject.api.introduction.IntroductionManager;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
@@ -20,6 +19,8 @@ import org.briarproject.util.StringUtils;
 import java.io.IOException;
 import java.util.logging.Logger;
 
+import javax.inject.Inject;
+
 import static java.util.logging.Level.WARNING;
 import static org.briarproject.api.introduction.IntroducerProtocolState.PREPARE_REQUESTS;
 import static org.briarproject.api.introduction.IntroductionConstants.AUTHOR_ID_1;
@@ -39,7 +40,6 @@ import static org.briarproject.api.introduction.IntroductionConstants.ROLE_INTRO
 import static org.briarproject.api.introduction.IntroductionConstants.SESSION_ID;
 import static org.briarproject.api.introduction.IntroductionConstants.STATE;
 import static org.briarproject.api.introduction.IntroductionConstants.STORAGE_ID;
-import static org.briarproject.api.introduction.IntroductionConstants.TIME;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_ABORT;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
@@ -49,19 +49,22 @@ class IntroducerManager {
 	private static final Logger LOG =
 		Logger.getLogger(IntroducerManager.class.getName());
 
-	private final IntroductionManager introductionManager;
+	private final MessageSender messageSender;
 	private final ClientHelper clientHelper;
 	private final Clock clock;
 	private final CryptoComponent cryptoComponent;
+	private final IntroductionGroupFactory introductionGroupFactory;
 
-	IntroducerManager(IntroductionManager introductionManager,
-			ClientHelper clientHelper, Clock clock,
-			CryptoComponent cryptoComponent) {
+	@Inject
+	IntroducerManager(MessageSender messageSender, ClientHelper clientHelper,
+			Clock clock, CryptoComponent cryptoComponent,
+			IntroductionGroupFactory introductionGroupFactory) {
 
-		this.introductionManager = introductionManager;
+		this.messageSender = messageSender;
 		this.clientHelper = clientHelper;
 		this.clock = clock;
 		this.cryptoComponent = cryptoComponent;
+		this.introductionGroupFactory = introductionGroupFactory;
 	}
 
 	public BdfDictionary initialize(Transaction txn, Contact c1, Contact c2)
@@ -72,13 +75,13 @@ class IntroducerManager {
 		Bytes salt = new Bytes(new byte[64]);
 		cryptoComponent.getSecureRandom().nextBytes(salt.getBytes());
 
-		Message m = clientHelper
-				.createMessage(introductionManager.getLocalGroup().getId(), now,
-						BdfList.of(salt));
+		Message m = clientHelper.createMessage(
+				introductionGroupFactory.createLocalGroup().getId(), now,
+				BdfList.of(salt));
 		MessageId sessionId = m.getId();
 
-		Group g1 = introductionManager.getIntroductionGroup(c1);
-		Group g2 = introductionManager.getIntroductionGroup(c2);
+		Group g1 = introductionGroupFactory.createIntroductionGroup(c1);
+		Group g2 = introductionGroupFactory.createIntroductionGroup(c2);
 
 		BdfDictionary d = new BdfDictionary();
 		d.put(SESSION_ID, sessionId);
@@ -95,7 +98,9 @@ class IntroducerManager {
 		d.put(AUTHOR_ID_2, c2.getAuthor().getId());
 
 		// save local state to database
-		clientHelper.addLocalMessage(txn, m, introductionManager.getClientId(), d, false);
+		clientHelper
+				.addLocalMessage(txn, m, IntroductionManagerImpl.CLIENT_ID, d,
+						false);
 
 		return d;
 	}
@@ -143,7 +148,7 @@ class IntroducerManager {
 
 		// send messages
 		for (BdfDictionary d : result.toSend) {
-			introductionManager.sendMessage(txn, d);
+			messageSender.sendMessage(txn, d);
 		}
 
 		// broadcast events
diff --git a/briar-core/src/org/briarproject/introduction/IntroductionGroupFactory.java b/briar-core/src/org/briarproject/introduction/IntroductionGroupFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..a255c8114538c0ad464e8a52eb6ecfee21400240
--- /dev/null
+++ b/briar-core/src/org/briarproject/introduction/IntroductionGroupFactory.java
@@ -0,0 +1,30 @@
+package org.briarproject.introduction;
+
+import org.briarproject.api.clients.PrivateGroupFactory;
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.sync.Group;
+
+import javax.inject.Inject;
+
+class IntroductionGroupFactory {
+
+	final private PrivateGroupFactory privateGroupFactory;
+	final private Group localGroup;
+
+	@Inject
+	IntroductionGroupFactory(PrivateGroupFactory privateGroupFactory) {
+		this.privateGroupFactory = privateGroupFactory;
+		localGroup = privateGroupFactory
+				.createLocalGroup(IntroductionManagerImpl.CLIENT_ID);
+	}
+
+	public Group createIntroductionGroup(Contact c) {
+		return privateGroupFactory
+				.createPrivateGroup(IntroductionManagerImpl.CLIENT_ID, c);
+	}
+
+	public Group createLocalGroup() {
+		return localGroup;
+	}
+
+}
diff --git a/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java b/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java
index edec27bef53a4d1042639557c96d65f03f4bca06..3550d0148960c92e4677dea85e9180fa6aca70c5 100644
--- a/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java
+++ b/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java
@@ -3,25 +3,17 @@ package org.briarproject.introduction;
 import org.briarproject.api.FormatException;
 import org.briarproject.api.clients.Client;
 import org.briarproject.api.clients.ClientHelper;
-import org.briarproject.api.clients.MessageQueueManager;
-import org.briarproject.api.clients.PrivateGroupFactory;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.contact.ContactManager;
 import org.briarproject.api.contact.ContactManager.AddContactHook;
 import org.briarproject.api.contact.ContactManager.RemoveContactHook;
-import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.data.BdfDictionary;
-import org.briarproject.api.data.BdfEntry;
 import org.briarproject.api.data.BdfList;
-import org.briarproject.api.data.MetadataEncoder;
 import org.briarproject.api.data.MetadataParser;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
-import org.briarproject.api.db.Metadata;
 import org.briarproject.api.db.NoSuchMessageException;
 import org.briarproject.api.db.Transaction;
-import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.introduction.IntroducerProtocolState;
 import org.briarproject.api.introduction.IntroductionManager;
@@ -29,7 +21,6 @@ import org.briarproject.api.introduction.IntroductionMessage;
 import org.briarproject.api.introduction.IntroductionRequest;
 import org.briarproject.api.introduction.IntroductionResponse;
 import org.briarproject.api.introduction.SessionId;
-import org.briarproject.api.properties.TransportPropertyManager;
 import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
@@ -94,37 +85,22 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 			Logger.getLogger(IntroductionManagerImpl.class.getName());
 
 	private final DatabaseComponent db;
-	private final MessageQueueManager messageQueueManager;
-	private final PrivateGroupFactory privateGroupFactory;
-	private final MetadataEncoder metadataEncoder;
 	private final IntroducerManager introducerManager;
 	private final IntroduceeManager introduceeManager;
-	private final Group localGroup;
+	private final IntroductionGroupFactory introductionGroupFactory;
 
 	@Inject
-	IntroductionManagerImpl(DatabaseComponent db,
-			MessageQueueManager messageQueueManager,
-			ClientHelper clientHelper, PrivateGroupFactory privateGroupFactory,
-			MetadataEncoder metadataEncoder, MetadataParser metadataParser,
-			CryptoComponent cryptoComponent,
-			TransportPropertyManager transportPropertyManager,
-			AuthorFactory authorFactory, ContactManager contactManager,
-			Clock clock) {
+	IntroductionManagerImpl(DatabaseComponent db, ClientHelper clientHelper,
+			MetadataParser metadataParser, Clock clock,
+			IntroducerManager introducerManager,
+			IntroduceeManager introduceeManager,
+			IntroductionGroupFactory introductionGroupFactory) {
 
 		super(clientHelper, metadataParser, clock);
 		this.db = db;
-		this.messageQueueManager = messageQueueManager;
-		this.privateGroupFactory = privateGroupFactory;
-		this.metadataEncoder = metadataEncoder;
-		// TODO: Inject these dependencies for easier testing
-		this.introducerManager =
-				new IntroducerManager(this, clientHelper, clock,
-						cryptoComponent);
-		this.introduceeManager =
-				new IntroduceeManager(db, this, clientHelper, clock,
-						cryptoComponent, transportPropertyManager,
-						authorFactory, contactManager);
-		localGroup = privateGroupFactory.createLocalGroup(CLIENT_ID);
+		this.introducerManager = introducerManager;
+		this.introduceeManager = introduceeManager;
+		this.introductionGroupFactory = introductionGroupFactory;
 	}
 
 	@Override
@@ -134,7 +110,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 
 	@Override
 	public void createLocalState(Transaction txn) throws DbException {
-		db.addGroup(txn, localGroup);
+		db.addGroup(txn, introductionGroupFactory.createLocalGroup());
 		// Ensure we've set things up for any pre-existing contacts
 		for (Contact c : db.getContacts(txn)) addingContact(txn, c);
 	}
@@ -143,7 +119,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 	public void addingContact(Transaction txn, Contact c) throws DbException {
 		try {
 			// Create an introduction group for sending introduction messages
-			Group g = getIntroductionGroup(c);
+			Group g = introductionGroupFactory.createIntroductionGroup(c);
 			// Return if we've already set things up for this contact
 			if (db.containsGroup(txn, g.getId())) return;
 			// Store the group and share it with the contact
@@ -164,7 +140,8 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 		Long id = (long) c.getId().getInt();
 		try {
 			Map<MessageId, BdfDictionary> map = clientHelper
-					.getMessageMetadataAsDictionary(txn, localGroup.getId());
+					.getMessageMetadataAsDictionary(txn,
+							introductionGroupFactory.createLocalGroup().getId());
 			for (Map.Entry<MessageId, BdfDictionary> entry : map.entrySet()) {
 				BdfDictionary d = entry.getValue();
 				long role = d.getLong(ROLE, -1L);
@@ -185,7 +162,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 
 		// remove the group (all messages will be removed with it)
 		// this contact won't get our abort message, but the other will
-		db.removeGroup(txn, getIntroductionGroup(c));
+		db.removeGroup(txn, introductionGroupFactory.createIntroductionGroup(c));
 	}
 
 	/**
@@ -287,8 +264,12 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 
 		Transaction txn = db.startTransaction(false);
 		try {
-			introduceeManager
-					.acceptIntroduction(txn, contactId, sessionId, timestamp);
+			Contact c = db.getContact(txn, contactId);
+			Group g = introductionGroupFactory.createIntroductionGroup(c);
+			BdfDictionary state =
+					getSessionState(txn, g.getId(), sessionId.getBytes());
+
+			introduceeManager.acceptIntroduction(txn, state, timestamp);
 			txn.setComplete();
 		} finally {
 			db.endTransaction(txn);
@@ -302,8 +283,12 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 
 		Transaction txn = db.startTransaction(false);
 		try {
-			introduceeManager
-					.declineIntroduction(txn, contactId, sessionId, timestamp);
+			Contact c = db.getContact(txn, contactId);
+			Group g = introductionGroupFactory.createIntroductionGroup(c);
+			BdfDictionary state =
+					getSessionState(txn, g.getId(), sessionId.getBytes());
+
+			introduceeManager.declineIntroduction(txn, state, timestamp);
 			txn.setComplete();
 		} finally {
 			db.endTransaction(txn);
@@ -322,8 +307,9 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 		Transaction txn = db.startTransaction(true);
 		try {
 			// get messages and their status
-			GroupId g =
-					getIntroductionGroup(db.getContact(txn, contactId)).getId();
+			GroupId g = introductionGroupFactory
+					.createIntroductionGroup(db.getContact(txn, contactId))
+					.getId();
 			metadata = clientHelper.getMessageMetadataAsDictionary(txn, g);
 			statuses = db.getMessageStatus(txn, contactId, g);
 
@@ -444,17 +430,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 		}
 	}
 
-	@Override
-	public void setReadFlag(MessageId m, boolean read) throws DbException {
-		try {
-			BdfDictionary meta = BdfDictionary.of(new BdfEntry(READ, read));
-			clientHelper.mergeMessageMetadata(m, meta);
-		} catch (FormatException e) {
-			throw new RuntimeException(e);
-		}
-	}
-
-	public BdfDictionary getSessionState(Transaction txn, GroupId groupId,
+	private BdfDictionary getSessionState(Transaction txn, GroupId groupId,
 			byte[] sessionId) throws DbException, FormatException {
 
 		try {
@@ -473,7 +449,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 			// to find state for introducee
 			Map<MessageId, BdfDictionary> map = clientHelper
 					.getMessageMetadataAsDictionary(txn,
-							localGroup.getId());
+							introductionGroupFactory.createLocalGroup().getId());
 			for (Map.Entry<MessageId, BdfDictionary> m : map.entrySet()) {
 				if (Arrays.equals(m.getValue().getRaw(SESSION_ID), sessionId)) {
 					BdfDictionary state = m.getValue();
@@ -490,35 +466,11 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 		}
 	}
 
-	public Group getIntroductionGroup(Contact c) {
-		return privateGroupFactory.createPrivateGroup(CLIENT_ID, c);
-	}
-
-	public Group getLocalGroup() {
-		return localGroup;
-	}
-
-	public void sendMessage(Transaction txn, BdfDictionary message)
-			throws DbException, FormatException {
-
-		BdfList bdfList = MessageEncoder.encodeMessage(message);
-		byte[] body = clientHelper.toByteArray(bdfList);
-		GroupId groupId = new GroupId(message.getRaw(GROUP_ID));
-		Group group = db.getGroup(txn, groupId);
-		long timestamp =
-				message.getLong(MESSAGE_TIME, System.currentTimeMillis());
-		message.put(MESSAGE_TIME, timestamp);
-
-		Metadata metadata = metadataEncoder.encode(message);
-
-		messageQueueManager
-				.sendMessage(txn, group, timestamp, body, metadata);
-	}
-
 	private void deleteMessage(Transaction txn, MessageId messageId)
 			throws DbException {
 
 		db.deleteMessage(txn, messageId);
 		db.deleteMessageMetadata(txn, messageId);
 	}
+
 }
diff --git a/briar-core/src/org/briarproject/introduction/IntroductionModule.java b/briar-core/src/org/briarproject/introduction/IntroductionModule.java
index 4fb0e493df2349fbe75da4acbe4a25f5932e18ea..633d14c15bbf8c579b259eca808a19b65ee7bee1 100644
--- a/briar-core/src/org/briarproject/introduction/IntroductionModule.java
+++ b/briar-core/src/org/briarproject/introduction/IntroductionModule.java
@@ -3,9 +3,13 @@ package org.briarproject.introduction;
 import org.briarproject.api.clients.ClientHelper;
 import org.briarproject.api.clients.MessageQueueManager;
 import org.briarproject.api.contact.ContactManager;
+import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.introduction.IntroductionManager;
 import org.briarproject.api.lifecycle.LifecycleManager;
+import org.briarproject.api.properties.TransportPropertyManager;
 import org.briarproject.api.system.Clock;
 
 import javax.inject.Inject;
diff --git a/briar-core/src/org/briarproject/introduction/MessageEncoder.java b/briar-core/src/org/briarproject/introduction/MessageSender.java
similarity index 52%
rename from briar-core/src/org/briarproject/introduction/MessageEncoder.java
rename to briar-core/src/org/briarproject/introduction/MessageSender.java
index 12153122cd51323d3975cfcbcc38b48fac39c15a..7152a1344ea90ea0b6a622061fe299c0c8f82788 100644
--- a/briar-core/src/org/briarproject/introduction/MessageEncoder.java
+++ b/briar-core/src/org/briarproject/introduction/MessageSender.java
@@ -1,11 +1,25 @@
 package org.briarproject.introduction;
 
 import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.MessageQueueManager;
 import org.briarproject.api.data.BdfDictionary;
 import org.briarproject.api.data.BdfList;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.system.Clock;
+
+import javax.inject.Inject;
 
 import static org.briarproject.api.introduction.IntroductionConstants.ACCEPT;
 import static org.briarproject.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.MESSAGE_TIME;
 import static org.briarproject.api.introduction.IntroductionConstants.MSG;
 import static org.briarproject.api.introduction.IntroductionConstants.NAME;
 import static org.briarproject.api.introduction.IntroductionConstants.PUBLIC_KEY;
@@ -18,9 +32,43 @@ import static org.briarproject.api.introduction.IntroductionConstants.TYPE_ACK;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_RESPONSE;
 
-public class MessageEncoder {
+class MessageSender {
+
+	final private DatabaseComponent db;
+	final private ClientHelper clientHelper;
+	final private Clock clock;
+	final private MetadataEncoder metadataEncoder;
+	final private MessageQueueManager messageQueueManager;
+
+	@Inject
+	MessageSender(DatabaseComponent db, ClientHelper clientHelper, Clock clock,
+			MetadataEncoder metadataEncoder,
+			MessageQueueManager messageQueueManager) {
+
+		this.db = db;
+		this.clientHelper = clientHelper;
+		this.clock = clock;
+		this.metadataEncoder = metadataEncoder;
+		this.messageQueueManager = messageQueueManager;
+	}
+
+	public void sendMessage(Transaction txn, BdfDictionary message)
+			throws DbException, FormatException {
+
+		BdfList bdfList = encodeMessage(message);
+		byte[] body = clientHelper.toByteArray(bdfList);
+		GroupId groupId = new GroupId(message.getRaw(GROUP_ID));
+		Group group = db.getGroup(txn, groupId);
+		long timestamp = clock.currentTimeMillis();
+
+		message.put(MESSAGE_TIME, timestamp);
+		Metadata metadata = metadataEncoder.encode(message);
+
+		messageQueueManager
+				.sendMessage(txn, group, timestamp, body, metadata);
+	}
 
-	public static BdfList encodeMessage(BdfDictionary d)
+	private BdfList encodeMessage(BdfDictionary d)
 			throws FormatException {
 
 		BdfList body;
@@ -39,7 +87,7 @@ public class MessageEncoder {
 		return body;
 	}
 
-	private static BdfList encodeRequest(BdfDictionary d)
+	private BdfList encodeRequest(BdfDictionary d)
 			throws FormatException {
 		BdfList list = BdfList.of(TYPE_REQUEST, d.getRaw(SESSION_ID),
 				d.getString(NAME), d.getRaw(PUBLIC_KEY));
@@ -50,7 +98,7 @@ public class MessageEncoder {
 		return list;
 	}
 
-	private static BdfList encodeResponse(BdfDictionary d)
+	private BdfList encodeResponse(BdfDictionary d)
 			throws FormatException {
 		BdfList list = BdfList.of(TYPE_RESPONSE, d.getRaw(SESSION_ID),
 				d.getBoolean(ACCEPT));
@@ -64,11 +112,11 @@ public class MessageEncoder {
 		return list;
 	}
 
-	private static BdfList encodeAck(BdfDictionary d) throws FormatException {
+	private BdfList encodeAck(BdfDictionary d) throws FormatException {
 		return BdfList.of(TYPE_ACK, d.getRaw(SESSION_ID));
 	}
 
-	private static BdfList encodeAbort(BdfDictionary d) throws FormatException {
+	private BdfList encodeAbort(BdfDictionary d) throws FormatException {
 		return BdfList.of(TYPE_ABORT, d.getRaw(SESSION_ID));
 	}
 
diff --git a/briar-tests/src/org/briarproject/introduction/IntroduceeManagerTest.java b/briar-tests/src/org/briarproject/introduction/IntroduceeManagerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..455c93603259bc875a5f05f53d5ba85405df72fd
--- /dev/null
+++ b/briar-tests/src/org/briarproject/introduction/IntroduceeManagerTest.java
@@ -0,0 +1,283 @@
+package org.briarproject.introduction;
+
+import org.briarproject.BriarTestCase;
+import org.briarproject.TestUtils;
+import org.briarproject.api.Bytes;
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.contact.ContactManager;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.introduction.IntroduceeProtocolState;
+import org.briarproject.api.introduction.SessionId;
+import org.briarproject.api.properties.TransportPropertyManager;
+import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.jmock.lib.legacy.ClassImposteriser;
+import org.junit.Test;
+
+import java.security.SecureRandom;
+
+import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.api.introduction.IntroduceeProtocolState.AWAIT_REQUEST;
+import static org.briarproject.api.introduction.IntroductionConstants.ACCEPT;
+import static org.briarproject.api.introduction.IntroductionConstants.ANSWERED;
+import static org.briarproject.api.introduction.IntroductionConstants.CONTACT;
+import static org.briarproject.api.introduction.IntroductionConstants.CONTACT_ID_1;
+import static org.briarproject.api.introduction.IntroductionConstants.EXISTS;
+import static org.briarproject.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.INTRODUCER;
+import static org.briarproject.api.introduction.IntroductionConstants.LOCAL_AUTHOR_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.MESSAGE_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.MESSAGE_TIME;
+import static org.briarproject.api.introduction.IntroductionConstants.NAME;
+import static org.briarproject.api.introduction.IntroductionConstants.NOT_OUR_RESPONSE;
+import static org.briarproject.api.introduction.IntroductionConstants.PUBLIC_KEY;
+import static org.briarproject.api.introduction.IntroductionConstants.REMOTE_AUTHOR_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.ROLE;
+import static org.briarproject.api.introduction.IntroductionConstants.ROLE_INTRODUCEE;
+import static org.briarproject.api.introduction.IntroductionConstants.SESSION_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.STATE;
+import static org.briarproject.api.introduction.IntroductionConstants.STORAGE_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.TIME;
+import static org.briarproject.api.introduction.IntroductionConstants.TRANSPORT;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_RESPONSE;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+public class IntroduceeManagerTest extends BriarTestCase {
+
+	final Mockery context;
+	final IntroduceeManager introduceeManager;
+	final DatabaseComponent db;
+	final CryptoComponent cryptoComponent;
+	final ClientHelper clientHelper;
+	final IntroductionGroupFactory introductionGroupFactory;
+	final MessageSender messageSender;
+	final TransportPropertyManager transportPropertyManager;
+	final AuthorFactory authorFactory;
+	final ContactManager contactManager;
+	final Clock clock;
+	final Contact introducer;
+	final Contact introducee1;
+	final Contact introducee2;
+	final Group localGroup1;
+	final Group introductionGroup1;
+	final Group introductionGroup2;
+	final Transaction txn;
+	final long time = 42L;
+	final Message localStateMessage;
+	final ClientId clientId;
+	final SessionId sessionId;
+	final Message message1;
+
+	public IntroduceeManagerTest() {
+		context = new Mockery();
+		context.setImposteriser(ClassImposteriser.INSTANCE);
+		messageSender = context.mock(MessageSender.class);
+		db = context.mock(DatabaseComponent.class);
+		cryptoComponent = context.mock(CryptoComponent.class);
+		clientHelper = context.mock(ClientHelper.class);
+		clock = context.mock(Clock.class);
+		introductionGroupFactory =
+				context.mock(IntroductionGroupFactory.class);
+		transportPropertyManager = context.mock(TransportPropertyManager.class);
+		authorFactory = context.mock(AuthorFactory.class);
+		contactManager = context.mock(ContactManager.class);
+
+		introduceeManager = new IntroduceeManager(messageSender, db,
+				clientHelper, clock, cryptoComponent, transportPropertyManager,
+				authorFactory, contactManager, introductionGroupFactory);
+
+		AuthorId authorId0 = new AuthorId(TestUtils.getRandomId());
+		Author author0 = new Author(authorId0, "Introducer",
+				TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+		AuthorId localAuthorId = new AuthorId(TestUtils.getRandomId());
+		ContactId contactId0 = new ContactId(234);
+		introducer = new Contact(contactId0, author0, localAuthorId, true);
+
+		AuthorId authorId1 = new AuthorId(TestUtils.getRandomId());
+		Author author1 = new Author(authorId1, "Introducee1",
+				TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+		AuthorId localAuthorId1 = new AuthorId(TestUtils.getRandomId());
+		ContactId contactId1 = new ContactId(234);
+		introducee1 = new Contact(contactId1, author1, localAuthorId1, true);
+
+		AuthorId authorId2 = new AuthorId(TestUtils.getRandomId());
+		Author author2 = new Author(authorId2, "Introducee2",
+				TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+		ContactId contactId2 = new ContactId(235);
+		introducee2 = new Contact(contactId2, author2, localAuthorId, true);
+
+		clientId = IntroductionManagerImpl.CLIENT_ID;
+		localGroup1 = new Group(new GroupId(TestUtils.getRandomId()),
+				clientId, new byte[0]);
+		introductionGroup1 = new Group(new GroupId(TestUtils.getRandomId()),
+				clientId, new byte[0]);
+		introductionGroup2 = new Group(new GroupId(TestUtils.getRandomId()),
+				clientId, new byte[0]);
+
+		sessionId = new SessionId(TestUtils.getRandomId());
+		localStateMessage = new Message(
+				new MessageId(TestUtils.getRandomId()),
+				localGroup1.getId(),
+				time,
+				TestUtils.getRandomBytes(MESSAGE_HEADER_LENGTH + 1)
+		);
+		message1 = new Message(
+				new MessageId(TestUtils.getRandomId()),
+				introductionGroup1.getId(),
+				time,
+				TestUtils.getRandomBytes(MESSAGE_HEADER_LENGTH + 1)
+		);
+
+		txn = new Transaction(null, false);
+	}
+
+	@Test
+	public void testIncomingRequestMessage()
+			throws DbException, FormatException {
+
+		final BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_REQUEST);
+		msg.put(GROUP_ID, introductionGroup1.getId());
+		msg.put(SESSION_ID, sessionId);
+		msg.put(MESSAGE_ID, message1.getId());
+		msg.put(MESSAGE_TIME, time);
+		msg.put(NAME, introducee2.getAuthor().getName());
+		msg.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
+
+		final BdfDictionary state =
+				initializeSessionState(txn, introductionGroup1.getId(), msg);
+
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).mergeMessageMetadata(txn,
+					localStateMessage.getId(), state);
+		}});
+
+		introduceeManager.incomingMessage(txn, state, msg);
+
+		context.assertIsSatisfied();
+
+		assertFalse(txn.isComplete());
+	}
+
+	@Test
+	public void testIncomingResponseMessage()
+			throws DbException, FormatException {
+
+		final BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_RESPONSE);
+		msg.put(GROUP_ID, introductionGroup1.getId());
+		msg.put(SESSION_ID, sessionId);
+		msg.put(MESSAGE_ID, message1.getId());
+		msg.put(MESSAGE_TIME, time);
+		msg.put(NAME, introducee2.getAuthor().getName());
+		msg.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
+
+		final BdfDictionary state =
+				initializeSessionState(txn, introductionGroup1.getId(), msg);
+		state.put(STATE, IntroduceeProtocolState.AWAIT_RESPONSES.ordinal());
+
+		// turn request message into a response
+		msg.put(ACCEPT, true);
+		msg.put(TIME, time);
+		msg.put(E_PUBLIC_KEY, TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+		msg.put(TRANSPORT, new BdfDictionary());
+
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).mergeMessageMetadata(txn,
+					localStateMessage.getId(), state);
+		}});
+
+		introduceeManager.incomingMessage(txn, state, msg);
+
+		context.assertIsSatisfied();
+
+		assertFalse(txn.isComplete());
+	}
+
+	private BdfDictionary initializeSessionState(final Transaction txn,
+			final GroupId groupId, final BdfDictionary msg)
+			throws DbException, FormatException {
+
+		final SecureRandom secureRandom = context.mock(SecureRandom.class);
+		final Bytes salt = new Bytes(new byte[64]);
+		final BdfDictionary groupMetadata = BdfDictionary.of(
+				new BdfEntry(CONTACT, introducee1.getId().getInt())
+		);
+		final boolean contactExists = true;
+		final BdfDictionary state = new BdfDictionary();
+		state.put(STORAGE_ID, localStateMessage.getId());
+		state.put(STATE, AWAIT_REQUEST.getValue());
+		state.put(ROLE, ROLE_INTRODUCEE);
+		state.put(GROUP_ID, groupId);
+		state.put(INTRODUCER, introducer.getAuthor().getName());
+		state.put(CONTACT_ID_1, introducer.getId().getInt());
+		state.put(LOCAL_AUTHOR_ID, introducer.getLocalAuthorId().getBytes());
+		state.put(NOT_OUR_RESPONSE, localStateMessage.getId());
+		state.put(ANSWERED, false);
+		state.put(EXISTS, true);
+		state.put(REMOTE_AUTHOR_ID, introducee2.getAuthor().getId());
+
+		context.checking(new Expectations() {{
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(time));
+			oneOf(cryptoComponent).getSecureRandom();
+			will(returnValue(secureRandom));
+			oneOf(secureRandom).nextBytes(salt.getBytes());
+			oneOf(introductionGroupFactory).createLocalGroup();
+			will(returnValue(localGroup1));
+			oneOf(clientHelper)
+					.createMessage(localGroup1.getId(), time, BdfList.of(salt));
+			will(returnValue(localStateMessage));
+
+			// who is making the introduction? who is the introducer?
+			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
+					groupId);
+			will(returnValue(groupMetadata));
+			oneOf(db).getContact(txn, introducer.getId());
+			will(returnValue(introducer));
+
+			// create remote author to check if contact exists
+			oneOf(authorFactory).createAuthor(introducee2.getAuthor().getName(),
+					introducee2.getAuthor().getPublicKey());
+			will(returnValue(introducee2.getAuthor()));
+			oneOf(contactManager)
+					.contactExists(txn, introducee2.getAuthor().getId(),
+							introducer.getLocalAuthorId());
+			will(returnValue(contactExists));
+
+			// store session state
+			oneOf(clientHelper)
+					.addLocalMessage(txn, localStateMessage, clientId, state,
+							false);
+		}});
+
+		BdfDictionary result = introduceeManager.initialize(txn, groupId, msg);
+
+		context.assertIsSatisfied();
+		return result;
+	}
+
+}
diff --git a/briar-tests/src/org/briarproject/introduction/IntroducerManagerTest.java b/briar-tests/src/org/briarproject/introduction/IntroducerManagerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..9f4cc21746de77fe94fa1d63e75171ffc9d28d9e
--- /dev/null
+++ b/briar-tests/src/org/briarproject/introduction/IntroducerManagerTest.java
@@ -0,0 +1,189 @@
+package org.briarproject.introduction;
+
+import org.briarproject.BriarTestCase;
+import org.briarproject.TestUtils;
+import org.briarproject.api.Bytes;
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.jmock.lib.legacy.ClassImposteriser;
+import org.junit.Test;
+
+import java.security.SecureRandom;
+
+import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.api.introduction.IntroducerProtocolState.AWAIT_RESPONSES;
+import static org.briarproject.api.introduction.IntroducerProtocolState.PREPARE_REQUESTS;
+import static org.briarproject.api.introduction.IntroductionConstants.AUTHOR_ID_1;
+import static org.briarproject.api.introduction.IntroductionConstants.AUTHOR_ID_2;
+import static org.briarproject.api.introduction.IntroductionConstants.CONTACT_1;
+import static org.briarproject.api.introduction.IntroductionConstants.CONTACT_2;
+import static org.briarproject.api.introduction.IntroductionConstants.CONTACT_ID_1;
+import static org.briarproject.api.introduction.IntroductionConstants.CONTACT_ID_2;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID_1;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID_2;
+import static org.briarproject.api.introduction.IntroductionConstants.MESSAGE_TIME;
+import static org.briarproject.api.introduction.IntroductionConstants.NAME;
+import static org.briarproject.api.introduction.IntroductionConstants.PUBLIC_KEY;
+import static org.briarproject.api.introduction.IntroductionConstants.ROLE;
+import static org.briarproject.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
+import static org.briarproject.api.introduction.IntroductionConstants.SESSION_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.STATE;
+import static org.briarproject.api.introduction.IntroductionConstants.STORAGE_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
+import static org.junit.Assert.assertFalse;
+
+public class IntroducerManagerTest extends BriarTestCase {
+
+	final Mockery context;
+	final IntroducerManager introducerManager;
+	final CryptoComponent cryptoComponent;
+	final ClientHelper clientHelper;
+	final IntroductionGroupFactory introductionGroupFactory;
+	final MessageSender messageSender;
+	final Clock clock;
+	final Contact introducee1;
+	final Contact introducee2;
+	final Group localGroup0;
+	final Group introductionGroup1;
+	final Group introductionGroup2;
+
+	public IntroducerManagerTest() {
+		context = new Mockery();
+		context.setImposteriser(ClassImposteriser.INSTANCE);
+		messageSender = context.mock(MessageSender.class);
+		cryptoComponent = context.mock(CryptoComponent.class);
+		clientHelper = context.mock(ClientHelper.class);
+		clock = context.mock(Clock.class);
+		introductionGroupFactory =
+				context.mock(IntroductionGroupFactory.class);
+
+		introducerManager =
+				new IntroducerManager(messageSender, clientHelper, clock,
+						cryptoComponent, introductionGroupFactory);
+
+		AuthorId authorId1 = new AuthorId(TestUtils.getRandomId());
+		Author author1 = new Author(authorId1, "Introducee1",
+				TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+		AuthorId localAuthorId1 = new AuthorId(TestUtils.getRandomId());
+		ContactId contactId1 = new ContactId(234);
+		introducee1 = new Contact(contactId1, author1, localAuthorId1, true);
+
+		AuthorId authorId2 = new AuthorId(TestUtils.getRandomId());
+		Author author2 = new Author(authorId2, "Introducee2",
+				TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+		AuthorId localAuthorId2 = new AuthorId(TestUtils.getRandomId());
+		ContactId contactId2 = new ContactId(235);
+		introducee2 = new Contact(contactId2, author2, localAuthorId2, true);
+
+		localGroup0 = new Group(new GroupId(TestUtils.getRandomId()),
+				getClientId(), new byte[0]);
+		introductionGroup1 = new Group(new GroupId(TestUtils.getRandomId()),
+				getClientId(), new byte[0]);
+		introductionGroup2 = new Group(new GroupId(TestUtils.getRandomId()),
+				getClientId(), new byte[0]);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testMakeIntroduction() throws DbException, FormatException {
+		final Transaction txn = new Transaction(null, false);
+		final long time = 42L;
+		context.setImposteriser(ClassImposteriser.INSTANCE);
+		final SecureRandom secureRandom = context.mock(SecureRandom.class);
+		final Bytes salt = new Bytes(new byte[64]);
+		final Message msg = new Message(new MessageId(TestUtils.getRandomId()),
+				localGroup0.getId(), time, TestUtils.getRandomBytes(64));
+		final BdfDictionary state = new BdfDictionary();
+		state.put(SESSION_ID, msg.getId());
+		state.put(STORAGE_ID, msg.getId());
+		state.put(STATE, PREPARE_REQUESTS.getValue());
+		state.put(ROLE, ROLE_INTRODUCER);
+		state.put(GROUP_ID_1, introductionGroup1.getId());
+		state.put(GROUP_ID_2, introductionGroup2.getId());
+		state.put(CONTACT_1, introducee1.getAuthor().getName());
+		state.put(CONTACT_2, introducee2.getAuthor().getName());
+		state.put(CONTACT_ID_1, introducee1.getId().getInt());
+		state.put(CONTACT_ID_2, introducee2.getId().getInt());
+		state.put(AUTHOR_ID_1, introducee1.getAuthor().getId());
+		state.put(AUTHOR_ID_2, introducee2.getAuthor().getId());
+		final BdfDictionary state2 = (BdfDictionary) state.clone();
+		state2.put(STATE, AWAIT_RESPONSES.getValue());
+
+		final BdfDictionary msg1 = new BdfDictionary();
+		msg1.put(TYPE, TYPE_REQUEST);
+		msg1.put(SESSION_ID, state.getRaw(SESSION_ID));
+		msg1.put(GROUP_ID, state.getRaw(GROUP_ID_1));
+		msg1.put(NAME, state.getString(CONTACT_2));
+		msg1.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
+		final BdfDictionary msg1send = (BdfDictionary) msg1.clone();
+		msg1send.put(MESSAGE_TIME, time);
+
+		final BdfDictionary msg2 = new BdfDictionary();
+		msg2.put(TYPE, TYPE_REQUEST);
+		msg2.put(SESSION_ID, state.getRaw(SESSION_ID));
+		msg2.put(GROUP_ID, state.getRaw(GROUP_ID_2));
+		msg2.put(NAME, state.getString(CONTACT_1));
+		msg2.put(PUBLIC_KEY, introducee1.getAuthor().getPublicKey());
+		final BdfDictionary msg2send = (BdfDictionary) msg2.clone();
+		msg2send.put(MESSAGE_TIME, time);
+
+		context.checking(new Expectations() {{
+			// initialize and store session state
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(time));
+			oneOf(cryptoComponent).getSecureRandom();
+			will(returnValue(secureRandom));
+			oneOf(secureRandom).nextBytes(salt.getBytes());
+			oneOf(introductionGroupFactory).createLocalGroup();
+			will(returnValue(localGroup0));
+			oneOf(clientHelper).createMessage(localGroup0.getId(), time,
+					BdfList.of(salt));
+			will(returnValue(msg));
+			oneOf(introductionGroupFactory)
+					.createIntroductionGroup(introducee1);
+			will(returnValue(introductionGroup1));
+			oneOf(introductionGroupFactory)
+					.createIntroductionGroup(introducee2);
+			will(returnValue(introductionGroup2));
+			oneOf(clientHelper).addLocalMessage(txn, msg, getClientId(), state,
+					false);
+
+			// send message
+			oneOf(clientHelper).mergeMessageMetadata(txn, msg.getId(), state2);
+			oneOf(messageSender).sendMessage(txn, msg1send);
+			oneOf(messageSender).sendMessage(txn, msg2send);
+		}});
+
+		introducerManager
+				.makeIntroduction(txn, introducee1, introducee2, null, time);
+
+		context.assertIsSatisfied();
+
+		assertFalse(txn.isComplete());
+	}
+
+	private ClientId getClientId() {
+		return IntroductionManagerImpl.CLIENT_ID;
+	}
+
+}
diff --git a/briar-tests/src/org/briarproject/introduction/IntroductionManagerImplTest.java b/briar-tests/src/org/briarproject/introduction/IntroductionManagerImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f6b5e90e88d5f375bc7a325722873ef9abf2c1b9
--- /dev/null
+++ b/briar-tests/src/org/briarproject/introduction/IntroductionManagerImplTest.java
@@ -0,0 +1,287 @@
+package org.briarproject.introduction;
+
+import org.briarproject.BriarTestCase;
+import org.briarproject.TestUtils;
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.MessageQueueManager;
+import org.briarproject.api.clients.PrivateGroupFactory;
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.data.MetadataParser;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.introduction.SessionId;
+import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
+import org.briarproject.api.system.Clock;
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.jmock.lib.legacy.ClassImposteriser;
+import org.junit.Test;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import static junit.framework.TestCase.assertTrue;
+import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID_1;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID_2;
+import static org.briarproject.api.introduction.IntroductionConstants.ROLE;
+import static org.briarproject.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
+import static org.briarproject.api.introduction.IntroductionConstants.SESSION_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_RESPONSE;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.junit.Assert.assertFalse;
+
+public class IntroductionManagerImplTest extends BriarTestCase {
+
+	final Mockery context;
+	final IntroductionManagerImpl introductionManager;
+	final IntroducerManager introducerManager;
+	final IntroduceeManager introduceeManager;
+	final DatabaseComponent db;
+	final PrivateGroupFactory privateGroupFactory;
+	final ClientHelper clientHelper;
+	final MetadataEncoder metadataEncoder;
+	final MessageQueueManager messageQueueManager;
+	final IntroductionGroupFactory introductionGroupFactory;
+	final Clock clock;
+	final SessionId sessionId = new SessionId(TestUtils.getRandomId());
+	final long time = 42L;
+	final Contact introducee1;
+	final Contact introducee2;
+	final Group localGroup0;
+	final Group introductionGroup1;
+	final Group introductionGroup2;
+	final Message message1;
+	Transaction txn;
+
+	public IntroductionManagerImplTest() {
+		AuthorId authorId1 = new AuthorId(TestUtils.getRandomId());
+		Author author1 = new Author(authorId1, "Introducee1",
+				new byte[MAX_PUBLIC_KEY_LENGTH]);
+		AuthorId localAuthorId1 = new AuthorId(TestUtils.getRandomId());
+		ContactId contactId1 = new ContactId(234);
+		introducee1 = new Contact(contactId1, author1, localAuthorId1, true);
+
+		AuthorId authorId2 = new AuthorId(TestUtils.getRandomId());
+		Author author2 = new Author(authorId2, "Introducee2",
+				new byte[MAX_PUBLIC_KEY_LENGTH]);
+		AuthorId localAuthorId2 = new AuthorId(TestUtils.getRandomId());
+		ContactId contactId2 = new ContactId(235);
+		introducee2 = new Contact(contactId2, author2, localAuthorId2, true);
+
+		ClientId clientId = new ClientId(TestUtils.getRandomId());
+		localGroup0 = new Group(new GroupId(TestUtils.getRandomId()),
+				clientId, new byte[0]);
+		introductionGroup1 = new Group(new GroupId(TestUtils.getRandomId()),
+				clientId, new byte[0]);
+		introductionGroup2 = new Group(new GroupId(TestUtils.getRandomId()),
+				clientId, new byte[0]);
+
+		message1 = new Message(
+				new MessageId(TestUtils.getRandomId()),
+				introductionGroup1.getId(),
+				time,
+				TestUtils.getRandomBytes(MESSAGE_HEADER_LENGTH + 1)
+		);
+
+		// mock ALL THE THINGS!!!
+		context = new Mockery();
+		context.setImposteriser(ClassImposteriser.INSTANCE);
+		introducerManager = context.mock(IntroducerManager.class);
+		introduceeManager = context.mock(IntroduceeManager.class);
+		db = context.mock(DatabaseComponent.class);
+		privateGroupFactory = context.mock(PrivateGroupFactory.class);
+		clientHelper = context.mock(ClientHelper.class);
+		metadataEncoder =
+				context.mock(MetadataEncoder.class);
+		messageQueueManager =
+				context.mock(MessageQueueManager.class);
+		MetadataParser metadataParser = context.mock(MetadataParser.class);
+		introductionGroupFactory = context.mock(IntroductionGroupFactory.class);
+		clock = context.mock(Clock.class);
+
+		introductionManager = new IntroductionManagerImpl(
+				db, clientHelper, metadataParser, clock, introducerManager,
+				introduceeManager, introductionGroupFactory
+		);
+	}
+
+	@Test
+	public void testMakeIntroduction() throws DbException, FormatException {
+		txn = new Transaction(null, false);
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(introducerManager)
+					.makeIntroduction(txn, introducee1, introducee2, null,
+							time);
+			oneOf(db).endTransaction(txn);
+		}});
+
+		introductionManager
+				.makeIntroduction(introducee1, introducee2, null, time);
+
+		context.assertIsSatisfied();
+		assertTrue(txn.isComplete());
+	}
+
+	@Test
+	public void testAcceptIntroduction() throws DbException, FormatException {
+		final BdfDictionary state = BdfDictionary.of(
+				new BdfEntry(GROUP_ID_1, introductionGroup1.getId()),
+				new BdfEntry(GROUP_ID_2, introductionGroup2.getId())
+		);
+		txn = new Transaction(null, false);
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, introducee1.getId());
+			will(returnValue(introducee1));
+			oneOf(introductionGroupFactory).createIntroductionGroup(introducee1);
+			will(returnValue(introductionGroup1));
+			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, sessionId);
+			will(returnValue(state));
+			oneOf(introduceeManager).acceptIntroduction(txn, state, time);
+			oneOf(db).endTransaction(txn);
+		}});
+
+		introductionManager
+				.acceptIntroduction(introducee1.getId(), sessionId, time);
+
+		context.assertIsSatisfied();
+		assertTrue(txn.isComplete());
+	}
+
+	@Test
+	public void testDeclineIntroduction() throws DbException, FormatException {
+		final BdfDictionary state = BdfDictionary.of(
+				new BdfEntry(GROUP_ID_1, introductionGroup1.getId()),
+				new BdfEntry(GROUP_ID_2, introductionGroup2.getId())
+		);
+		txn = new Transaction(null, false);
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, introducee1.getId());
+			will(returnValue(introducee1));
+			oneOf(introductionGroupFactory).createIntroductionGroup(introducee1);
+			will(returnValue(introductionGroup1));
+			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, sessionId);
+			will(returnValue(state));
+			oneOf(introduceeManager).declineIntroduction(txn, state, time);
+			oneOf(db).endTransaction(txn);
+		}});
+
+		introductionManager
+				.declineIntroduction(introducee1.getId(), sessionId, time);
+
+		context.assertIsSatisfied();
+		assertTrue(txn.isComplete());
+	}
+
+	@Test
+	public void testGetIntroductionMessages()
+			throws DbException, FormatException {
+
+		final Map<MessageId, BdfDictionary> metadata = Collections.emptyMap();
+		final Collection<MessageStatus> statuses = Collections.emptyList();
+		txn = new Transaction(null, false);
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(true);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, introducee1.getId());
+			will(returnValue(introducee1));
+			oneOf(introductionGroupFactory).createIntroductionGroup(introducee1);
+			will(returnValue(introductionGroup1));
+			oneOf(clientHelper).getMessageMetadataAsDictionary(txn,
+					introductionGroup1.getId());
+			will(returnValue(metadata));
+			oneOf(db).getMessageStatus(txn, introducee1.getId(),
+					introductionGroup1.getId());
+			will(returnValue(statuses));
+			oneOf(db).endTransaction(txn);
+		}});
+
+		introductionManager.getIntroductionMessages(introducee1.getId());
+
+		context.assertIsSatisfied();
+		assertTrue(txn.isComplete());
+	}
+
+	@Test
+	public void testIncomingRequestMessage()
+			throws DbException, FormatException {
+
+		final BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_REQUEST);
+
+		final BdfDictionary state = new BdfDictionary();
+		txn = new Transaction(null, false);
+
+		context.checking(new Expectations() {{
+			oneOf(introduceeManager)
+					.initialize(txn, introductionGroup1.getId(), msg);
+			will(returnValue(state));
+			oneOf(introduceeManager)
+					.incomingMessage(txn, state, msg);
+		}});
+
+		introductionManager
+				.incomingMessage(txn, message1, new BdfList(), msg);
+
+		context.assertIsSatisfied();
+		assertFalse(txn.isComplete());
+	}
+
+	@Test
+	public void testIncomingResponseMessage()
+			throws DbException, FormatException {
+
+		final BdfDictionary msg = BdfDictionary.of(
+				new BdfEntry(TYPE, TYPE_RESPONSE),
+				new BdfEntry(SESSION_ID, sessionId)
+		);
+
+		final BdfDictionary state = new BdfDictionary();
+		state.put(ROLE, ROLE_INTRODUCER);
+		state.put(GROUP_ID_1, introductionGroup1.getId());
+		state.put(GROUP_ID_2, introductionGroup2.getId());
+
+		txn = new Transaction(null, false);
+
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, sessionId);
+			will(returnValue(state));
+			oneOf(introducerManager).incomingMessage(txn, state, msg);
+		}});
+
+		introductionManager
+				.incomingMessage(txn, message1, new BdfList(), msg);
+
+		context.assertIsSatisfied();
+		assertFalse(txn.isComplete());
+	}
+
+
+}
diff --git a/briar-tests/src/org/briarproject/introduction/IntroductionValidatorTest.java b/briar-tests/src/org/briarproject/introduction/IntroductionValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..dbacbc8f740273e4f6bf1988406ab0cccf00eee2
--- /dev/null
+++ b/briar-tests/src/org/briarproject/introduction/IntroductionValidatorTest.java
@@ -0,0 +1,357 @@
+package org.briarproject.introduction;
+
+import org.briarproject.BriarTestCase;
+import org.briarproject.TestUtils;
+import org.briarproject.api.FormatException;
+import org.briarproject.api.TransportId;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.introduction.SessionId;
+import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+import org.briarproject.system.SystemClock;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import java.io.IOException;
+
+import static org.briarproject.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
+import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.api.introduction.IntroductionConstants.ACCEPT;
+import static org.briarproject.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.MSG;
+import static org.briarproject.api.introduction.IntroductionConstants.NAME;
+import static org.briarproject.api.introduction.IntroductionConstants.PUBLIC_KEY;
+import static org.briarproject.api.introduction.IntroductionConstants.SESSION_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.TIME;
+import static org.briarproject.api.introduction.IntroductionConstants.TRANSPORT;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_ABORT;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_ACK;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_RESPONSE;
+import static org.briarproject.api.properties.TransportPropertyConstants.MAX_PROPERTY_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_MESSAGE_BODY_LENGTH;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+public class IntroductionValidatorTest extends BriarTestCase {
+
+	private final Mockery context = new Mockery();
+	private final Group group;
+	private final Message message;
+	private final IntroductionValidator validator;
+	private final Clock clock = new SystemClock();
+
+	public IntroductionValidatorTest() {
+		GroupId groupId = new GroupId(TestUtils.getRandomId());
+		ClientId clientId = new ClientId(TestUtils.getRandomId());
+		byte[] descriptor = TestUtils.getRandomBytes(12);
+		group = new Group(groupId, clientId, descriptor);
+
+		MessageId messageId = new MessageId(TestUtils.getRandomId());
+		long timestamp = System.currentTimeMillis();
+		byte[] raw = TestUtils.getRandomBytes(123);
+		message = new Message(messageId, group.getId(), timestamp, raw);
+
+
+		ClientHelper clientHelper = context.mock(ClientHelper.class);
+		MetadataEncoder metadataEncoder = context.mock(MetadataEncoder.class);
+		validator = new IntroductionValidator(clientHelper, metadataEncoder,
+				clock);
+		context.assertIsSatisfied();
+	}
+
+	//
+	// Introduction Requests
+	//
+
+	@Test
+	public void testValidateProperIntroductionRequest() throws IOException {
+		final byte[] sessionId = TestUtils.getRandomId();
+		final String name = TestUtils.getRandomString(MAX_AUTHOR_NAME_LENGTH);
+		final byte[] publicKey = TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+		final String text = TestUtils.getRandomString(MAX_MESSAGE_BODY_LENGTH);
+
+		BdfList body = BdfList.of(TYPE_REQUEST, sessionId,
+				name, publicKey, text);
+
+		final BdfDictionary result =
+				validator.validateMessage(message, group, body);
+
+		assertEquals(Long.valueOf(TYPE_REQUEST), result.getLong(TYPE));
+		assertEquals(sessionId, result.getRaw(SESSION_ID));
+		assertEquals(name, result.getString(NAME));
+		assertEquals(publicKey, result.getRaw(PUBLIC_KEY));
+		assertEquals(text, result.getString(MSG));
+		context.assertIsSatisfied();
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateIntroductionRequestWithNoName() throws IOException {
+		BdfDictionary msg = getValidIntroductionRequest();
+
+		// no NAME is message
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+					msg.getRaw(PUBLIC_KEY));
+		if (msg.containsKey(MSG)) body.add(msg.getString(MSG));
+
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateIntroductionRequestWithLongName() throws IOException {
+		// too long NAME in message
+		BdfDictionary msg = getValidIntroductionRequest();
+		msg.put(NAME, msg.get(NAME) + "x");
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+				msg.getString(NAME), msg.getRaw(PUBLIC_KEY));
+		if (msg.containsKey(MSG)) body.add(msg.getString(MSG));
+
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateIntroductionRequestWithWrongType()
+			throws IOException {
+		// wrong message type
+		BdfDictionary msg = getValidIntroductionRequest();
+		msg.put(TYPE, 324234);
+
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+				msg.getString(NAME), msg.getRaw(PUBLIC_KEY));
+		if (msg.containsKey(MSG)) body.add(msg.getString(MSG));
+		validator.validateMessage(message, group, body);
+	}
+
+	private BdfDictionary getValidIntroductionRequest() throws FormatException {
+		byte[] sessionId = TestUtils.getRandomId();
+		String name = TestUtils.getRandomString(MAX_AUTHOR_NAME_LENGTH);
+		byte[] publicKey = TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+		String text = TestUtils.getRandomString(MAX_MESSAGE_BODY_LENGTH);
+
+		BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_REQUEST);
+		msg.put(SESSION_ID, sessionId);
+		msg.put(NAME, name);
+		msg.put(PUBLIC_KEY, publicKey);
+		msg.put(MSG, text);
+
+		return msg;
+	}
+
+	//
+	// Introduction Responses
+	//
+
+	@Test
+	public void testValidateIntroductionAcceptResponse() throws IOException {
+		byte[] groupId = TestUtils.getRandomId();
+		byte[] sessionId = TestUtils.getRandomId();
+		long time = clock.currentTimeMillis();
+		byte[] publicKey = TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+		String transportId = TestUtils
+				.getRandomString(TransportId.MAX_TRANSPORT_ID_LENGTH);
+		BdfDictionary tProps = BdfDictionary.of(
+				new BdfEntry(TestUtils.getRandomString(MAX_PROPERTY_LENGTH),
+						TestUtils.getRandomString(MAX_PROPERTY_LENGTH))
+		);
+		BdfDictionary tp = BdfDictionary.of(
+				new BdfEntry(transportId, tProps)
+		);
+
+		BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_RESPONSE);
+		msg.put(GROUP_ID, groupId);
+		msg.put(SESSION_ID, sessionId);
+		msg.put(ACCEPT, true);
+		msg.put(TIME, time);
+		msg.put(E_PUBLIC_KEY, publicKey);
+		msg.put(TRANSPORT, tp);
+
+		BdfList body = BdfList.of(TYPE_RESPONSE, msg.getRaw(SESSION_ID),
+				msg.getBoolean(ACCEPT), msg.getLong(TIME),
+				msg.getRaw(E_PUBLIC_KEY), msg.getDictionary(TRANSPORT));
+
+		final BdfDictionary result =
+				validator.validateMessage(message, group, body);
+
+		assertEquals(Long.valueOf(TYPE_RESPONSE), result.getLong(TYPE));
+		assertEquals(sessionId, result.getRaw(SESSION_ID));
+		assertEquals(true, result.getBoolean(ACCEPT));
+		assertEquals(publicKey, result.getRaw(E_PUBLIC_KEY));
+		assertEquals(tp, result.getDictionary(TRANSPORT));
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testValidateIntroductionDeclineResponse()
+			throws IOException {
+		BdfDictionary msg = getValidIntroductionResponse(false);
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+				msg.getBoolean(ACCEPT));
+
+		BdfDictionary result = validator.validateMessage(message, group, body);
+
+		assertFalse(result.getBoolean(ACCEPT));
+		context.assertIsSatisfied();
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateIntroductionResponseWithoutAccept()
+			throws IOException {
+		BdfDictionary msg = getValidIntroductionResponse(false);
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID));
+
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateIntroductionResponseWithBrokenTp()
+			throws IOException {
+		BdfDictionary msg = getValidIntroductionResponse(true);
+		BdfDictionary tp = msg.getDictionary(TRANSPORT);
+		tp.put(TestUtils
+				.getRandomString(TransportId.MAX_TRANSPORT_ID_LENGTH), "X");
+		msg.put(TRANSPORT, tp);
+
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+				msg.getBoolean(ACCEPT), msg.getLong(TIME),
+				msg.getRaw(E_PUBLIC_KEY), msg.getDictionary(TRANSPORT));
+
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateIntroductionResponseWithoutPublicKey()
+			throws IOException {
+		BdfDictionary msg = getValidIntroductionResponse(true);
+
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+				msg.getBoolean(ACCEPT), msg.getLong(TIME),
+				msg.getDictionary(TRANSPORT));
+
+		validator.validateMessage(message, group, body);
+	}
+
+	private BdfDictionary getValidIntroductionResponse(boolean accept)
+			throws FormatException {
+
+		byte[] groupId = TestUtils.getRandomId();
+		byte[] sessionId = TestUtils.getRandomId();
+		long time = clock.currentTimeMillis();
+		byte[] publicKey = TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+		String transportId = TestUtils
+				.getRandomString(TransportId.MAX_TRANSPORT_ID_LENGTH);
+		BdfDictionary tProps = BdfDictionary.of(
+				new BdfEntry(TestUtils.getRandomString(MAX_PROPERTY_LENGTH),
+						TestUtils.getRandomString(MAX_PROPERTY_LENGTH))
+		);
+		BdfDictionary tp = BdfDictionary.of(
+				new BdfEntry(transportId, tProps)
+		);
+
+		BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_RESPONSE);
+		msg.put(GROUP_ID, groupId);
+		msg.put(SESSION_ID, sessionId);
+		msg.put(ACCEPT, accept);
+		if (accept) {
+			msg.put(TIME, time);
+			msg.put(E_PUBLIC_KEY, publicKey);
+			msg.put(TRANSPORT, tp);
+		}
+
+		return msg;
+	}
+
+	//
+	// Introduction ACK
+	//
+
+	@Test
+	public void testValidateProperIntroductionAck() throws IOException {
+		final byte[] sessionId = TestUtils.getRandomId();
+
+		BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_ACK);
+		msg.put(SESSION_ID, sessionId);
+
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID));
+
+		BdfDictionary result =
+				validator.validateMessage(message, group, body);
+
+		assertEquals(Long.valueOf(TYPE_ACK), result.getLong(TYPE));
+		assertEquals(sessionId, result.getRaw(SESSION_ID));
+		context.assertIsSatisfied();
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateTooLongIntroductionAck() throws IOException {
+		BdfDictionary msg = BdfDictionary.of(
+				new BdfEntry(TYPE, TYPE_ACK),
+				new BdfEntry(SESSION_ID, TestUtils.getRandomId()),
+				new BdfEntry("garbage", TestUtils.getRandomString(255))
+		);
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+				msg.getString("garbage"));
+
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateIntroductionAckWithLongSessionId() throws IOException {
+		BdfDictionary msg = BdfDictionary.of(
+				new BdfEntry(TYPE, TYPE_ACK),
+				new BdfEntry(SESSION_ID, new byte[SessionId.LENGTH + 1])
+		);
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID));
+
+		validator.validateMessage(message, group, body);
+	}
+
+	//
+	// Introduction Abort
+	//
+
+	@Test
+	public void testValidateProperIntroductionAbort() throws IOException {
+		byte[] sessionId = TestUtils.getRandomId();
+
+		BdfDictionary msg = new BdfDictionary();
+		msg.put(TYPE, TYPE_ABORT);
+		msg.put(SESSION_ID, sessionId);
+
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID));
+
+		BdfDictionary result =
+				validator.validateMessage(message, group, body);
+
+		assertEquals(Long.valueOf(TYPE_ABORT), result.getLong(TYPE));
+		assertEquals(sessionId, result.getRaw(SESSION_ID));
+		context.assertIsSatisfied();
+	}
+
+	@Test(expected = FormatException.class)
+	public void testValidateTooLongIntroductionAbort() throws IOException {
+		BdfDictionary msg = BdfDictionary.of(
+				new BdfEntry(TYPE, TYPE_ABORT),
+				new BdfEntry(SESSION_ID, TestUtils.getRandomId()),
+				new BdfEntry("garbage", TestUtils.getRandomString(255))
+		);
+		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
+				msg.getString("garbage"));
+
+		validator.validateMessage(message, group, body);
+	}
+
+}
diff --git a/briar-tests/src/org/briarproject/introduction/MessageSenderTest.java b/briar-tests/src/org/briarproject/introduction/MessageSenderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..1c9d3b82c72e5de6d9b3a84db37871be524f4e7e
--- /dev/null
+++ b/briar-tests/src/org/briarproject/introduction/MessageSenderTest.java
@@ -0,0 +1,95 @@
+package org.briarproject.introduction;
+
+import org.briarproject.BriarTestCase;
+import org.briarproject.TestUtils;
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.MessageQueueManager;
+import org.briarproject.api.clients.PrivateGroupFactory;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.introduction.SessionId;
+import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.system.Clock;
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import static junit.framework.Assert.assertFalse;
+import static org.briarproject.api.introduction.IntroductionConstants.GROUP_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.SESSION_ID;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE;
+import static org.briarproject.api.introduction.IntroductionConstants.TYPE_ACK;
+
+public class MessageSenderTest extends BriarTestCase {
+
+	final Mockery context;
+	final MessageSender messageSender;
+	final DatabaseComponent db;
+	final PrivateGroupFactory privateGroupFactory;
+	final ClientHelper clientHelper;
+	final MetadataEncoder metadataEncoder;
+	final MessageQueueManager messageQueueManager;
+	final Clock clock;
+
+	public MessageSenderTest() {
+		context = new Mockery();
+		db = context.mock(DatabaseComponent.class);
+		privateGroupFactory = context.mock(PrivateGroupFactory.class);
+		clientHelper = context.mock(ClientHelper.class);
+		metadataEncoder =
+				context.mock(MetadataEncoder.class);
+		messageQueueManager =
+				context.mock(MessageQueueManager.class);
+		clock = context.mock(Clock.class);
+
+		messageSender =
+				new MessageSender(db, clientHelper, clock, metadataEncoder,
+						messageQueueManager);
+	}
+
+	@Test
+	public void testSendMessage() throws DbException, FormatException {
+		final Transaction txn = new Transaction(null, false);
+		final Group privateGroup = new Group(new GroupId(TestUtils.getRandomId()),
+				new ClientId(TestUtils.getRandomId()), new byte[0]);
+		final SessionId sessionId = new SessionId(TestUtils.getRandomId());
+		final long time = 42L;
+		final BdfDictionary msg = BdfDictionary.of(
+				new BdfEntry(TYPE, TYPE_ACK),
+				new BdfEntry(GROUP_ID, privateGroup.getId()),
+				new BdfEntry(SESSION_ID, sessionId)
+		);
+		final BdfList bodyList =
+				BdfList.of(TYPE_ACK, msg.getRaw(SESSION_ID));
+		final byte[] body = TestUtils.getRandomBytes(8);
+		final Metadata metadata = new Metadata();
+
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).toByteArray(bodyList);
+			will(returnValue(body));
+			oneOf(db).getGroup(txn, privateGroup.getId());
+			will(returnValue(privateGroup));
+			oneOf(metadataEncoder).encode(msg);
+			will(returnValue(metadata));
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(time));
+			oneOf(messageQueueManager)
+					.sendMessage(txn, privateGroup, time, body, metadata);
+		}});
+
+		messageSender.sendMessage(txn, msg);
+
+		context.assertIsSatisfied();
+		assertFalse(txn.isComplete());
+	}
+
+}