diff --git a/VERSIONS_STABLE/18661 b/VERSIONS_STABLE/18661
new file mode 100644
index 0000000000000000000000000000000000000000..754b78113bb791424ebba5001d67f8f172334331
--- /dev/null
+++ b/VERSIONS_STABLE/18661
@@ -0,0 +1 @@
+ - ticket #18661 : dédoublonnage des mails à l'envoi d'une newsletter
\ No newline at end of file
diff --git a/library/Class/Batch/SendNewsletters.php b/library/Class/Batch/SendNewsletters.php
index bfd6c9cd21ee81d0d61a11d8e8b90155008db75d..21dc9d5aa0c2345ba877a011fbb835f06a7b2994 100644
--- a/library/Class/Batch/SendNewsletters.php
+++ b/library/Class/Batch/SendNewsletters.php
@@ -22,12 +22,14 @@
 
 
 class Class_Batch_SendNewsletters extends Class_Batch_Abstract {
-	protected $_newsletter;
+	protected
+		$_newsletter,
+		$_previous_mail,
+		$_time_limit;
 
 
 	public function __construct($newsletter) {
 		$this->_newsletter = $newsletter;
-		return $this;
 	}
 
 
@@ -50,11 +52,79 @@ class Class_Batch_SendNewsletters extends Class_Batch_Abstract {
 
 
 	public function run() {
-		exec("/usr/bin/php -f ".realpath(dirname(__FILE__))."/../../../scripts/sendNewsletter.php "
-				 .$this->getExecParams()
-				 ." > /dev/null &");
+		exec('/usr/bin/php -f '
+				 . realpath(dirname(__FILE__)) . '/../../../scripts/sendNewsletter.php '
+				 . $this->getExecParams()
+				 . ' > /dev/null &');
+		return $this;
+	}
+
+
+	public function sendAllBy($page_size) {
+		if (!$this->_newsletter)
+			return;
+
+		$letter = $this->_newsletter;
+
+		Class_NewsletterSubscription::resetSendFlagForNewsletter($letter->getId());
+
+		$this->_previous_mail = '';
+		while (0 < count(array_filter($receivers = $letter->getReceivers($page_size)))) {
+			$this->_clearMemory()
+					 ->_giveMeMoreTime(30)
+					 ->_sendPage($receivers);
+
+			$letter->setLastDistributionDateWithFormat();
+			Class_NewsletterSubscription::updateSendFlagForReceivers($letter->getId(),
+																															 $letter->getReceivers($page_size));
+		}
+
+		$this->getTimeLimit()->reset();
+	}
+
+
+	protected function _clearMemory() {
+		Class_NewsletterSubscription::clearCache();
+		Class_Users::clearCache();
+		Storm_Model_Loader::resetCache();
+		gc_collect_cycles();
+
+		return $this;
+	}
+
+
+	protected function _giveMeMoreTime($seconds) {
+		$this->getTimeLimit()->set($seconds);
 		return $this;
 	}
-}
 
-?>
\ No newline at end of file
+
+	protected function _sendPage($receivers) {
+		$letter = $this->_newsletter;
+
+		$mail = $letter->newMail();
+		$mail->addTo($letter->getExpediteur());
+
+		foreach($receivers as $receiver)
+			$this->_addReceiverTo($receiver, $mail);
+
+		$mail->send();
+	}
+
+
+	protected function _addReceiverTo($receiver, $mail) {
+		$receiver_mail = $receiver->getMail();
+		if ($this->_previous_mail != $receiver_mail) {
+			$mail->addBcc($receiver_mail);
+			$this->_previous_mail = $receiver_mail;
+		}
+	}
+
+
+	public function getTimeLimit() {
+		if (!$this->_time_limit)
+			$this->_time_limit = Class_Systeme_TimeLimit::getInstance();
+
+		return $this->_time_limit;
+	}
+}
\ No newline at end of file
diff --git a/library/Class/Newsletter.php b/library/Class/Newsletter.php
index 60bab4e402ee684d9a588829647aa2b4240399fd..4ed1a48102cb0ed106bae2714f419978657a90c7 100644
--- a/library/Class/Newsletter.php
+++ b/library/Class/Newsletter.php
@@ -16,7 +16,7 @@
  *
  * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
  * along with BOKEH; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA 
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
 /*
@@ -55,7 +55,7 @@
 
 class Class_Newsletter extends Storm_Model_Abstract {
 	use Trait_TimeSource, Trait_Translator;
-	
+
 	protected $_table_name = 'newsletters';
 	protected $_has_many = ['subscriptions' => ['model' => 'Class_NewsletterSubscription',
 																							'role' => 'newsletter',
@@ -69,7 +69,7 @@ class Class_Newsletter extends Storm_Model_Abstract {
 																										'limitPage' => [$page, $items_by_page]]);
 	}
 
-	
+
 	public function send() {
 		return (new Class_Batch_SendNewsletters($this))->run();
 	}
@@ -83,7 +83,7 @@ class Class_Newsletter extends Storm_Model_Abstract {
 
 
 	public function sendTo($destinataire) {
-		$mail = $this->_newMail();
+		$mail = $this->newMail();
 		$mail->addTo($destinataire);
 		$mail->send();
 	}
@@ -97,7 +97,7 @@ class Class_Newsletter extends Storm_Model_Abstract {
 	}
 
 
-	protected function _newMail() {
+	public function newMail() {
 		$notices = $this->getNotices();
 
 		$mail = new ZendAfi_Mail('utf8');
@@ -117,52 +117,33 @@ class Class_Newsletter extends Storm_Model_Abstract {
 
 	protected function getMailAt($index) {
 		$users = Class_NewsletterSubscription::getAvailableUsersForNewsletter($this->getId(), $index);
-			
-		$mail = $this->_newMail();
+
+		$mail = $this->newMail();
 		$mail->addTo($this->getExpediteur());
 
 		foreach($users as $user) {
-			($user_mail = $user->getMail()) 
+			($user_mail = $user->getMail())
 				? $mail->addBcc($user_mail)
 				: '';
 		}
 		return $mail;
 	}
 
-	
+
 
 	public function getNumberOfUsers() {
 		return sprintf('%05d', Class_NewsletterSubscription::countAvailableUserForNewsletter($this->getId()));
 	}
 
 
-
 	public function generateMails($recipient_size) {
-		Class_NewsletterSubscription::resetSendFlagForNewsletter($this->getId());
-		$time_limit = Class_Systeme_TimeLimit::getInstance();
-
-		while (0 < count(array_filter( $receivers = $this->getReceivers($recipient_size)))) {
-			Class_NewsletterSubscription::clearCache();
-			Class_Users::clearCache();
-			Storm_Model_Loader::resetCache();
-			gc_collect_cycles();
-			$time_limit->set(30);
-			
-			$mail = $this->_newMail();
-			$mail->addTo($this->getExpediteur());
-
-			foreach($receivers as $receiver)
-				$mail->addBcc($receiver->getMail());
-			$mail->send();
-			$this->setLastDistributionDateWithFormat();			
-			Class_NewsletterSubscription::updateSendFlagForReceivers($this->getId(),$this->getReceivers($recipient_size));
-		}
-		$time_limit->reset();
+		(new Class_Batch_SendNewsletters($this))
+			->sendAllBy($recipient_size);
 	}
 
 
-	protected function getReceivers($recipient_size) {
-			return Class_Users::getNewslettersReceivers($this->getId(), $recipient_size);
+	public function getReceivers($recipient_size) {
+		return Class_Users::getNewslettersReceivers($this->getId(), $recipient_size);
 	}
 
 
@@ -253,7 +234,7 @@ class Class_Newsletter extends Storm_Model_Abstract {
 		}
 
 		return $html;
-		
+
 	}
 
 
diff --git a/library/Class/NewsletterSubscription.php b/library/Class/NewsletterSubscription.php
index 873ec5c8638bc757f710225fdb7116669061fd40..b953d0cccd8a17aa889430de0c9c0cbf90b9b1d9 100644
--- a/library/Class/NewsletterSubscription.php
+++ b/library/Class/NewsletterSubscription.php
@@ -16,7 +16,7 @@
  *
  * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
  * along with BOKEH; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA 
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
 class NewsletterSubscriptionLoader extends Storm_Model_Loader {
@@ -25,7 +25,7 @@ class NewsletterSubscriptionLoader extends Storm_Model_Loader {
 		return Class_NewsletterSubscription::countBy($this->availableUserForNewsletter($id_newsletter));
 	}
 
-	
+
 	protected function clearInvalid($id_newsletter) {
 		Class_NewsletterSubscription::deleteBy($this->notAvailableUserForNewsletter($id_newsletter));
 	}
@@ -45,29 +45,30 @@ class NewsletterSubscriptionLoader extends Storm_Model_Loader {
 
 		if(!$subscriptions)
 			return [];
-		
+
 		$users = [];
 		foreach($subscriptions as $subscription) {
 			$user = Class_Users::find($subscription->getUserId());
 			if(!$user || !$user->getMail())
 				continue;
-			
+
 			$users[] = $user;
 		}
 		return array_filter($users);
 	}
 
-	
+
 	public function resetSendFlagForNewsletter($id_newsletter) {
 		$newsletters_not_already_send = Class_NewsletterSubscription::findAllBy(['newsletter_id' => $id_newsletter,
 																																						 'send' => false]);
-		if(1 > count(array_filter($newsletters_not_already_send)))
+
+		if(0 == count(array_filter($newsletters_not_already_send)))
 			return sqlExecute("update newsletters_users set send=false where newsletter_id=".$id_newsletter);
 
 		return '';
 	}
 
-	
+
 	public function updateSendFlagForReceivers($id_newsletter,$receivers) {
 		$users_ids_list = [];
 		foreach ($receivers as $user) {
@@ -92,11 +93,11 @@ class NewsletterSubscriptionLoader extends Storm_Model_Loader {
 		return ['where' => 'newsletter_id = '.$id_newsletter.' and '.$this->usersWithNoMail()];
 	}
 
-	
+
 	protected function usersWithNoMail() {
 		return 'user_id not in ('.$this->selectUser().')';
 	}
-	
+
 
 	protected function validUsers() {
 		return 'user_id in ('.$this->selectUser().')';
@@ -115,12 +116,12 @@ class Class_NewsletterSubscription extends Storm_Model_Abstract {
   protected $_loader_class = 'NewsletterSubscriptionLoader';
 	protected $_belongs_to = 	['user' => ['model' => 'Class_Users'],
 														 'newsletter' => ['model' => 'Class_Newsletter']];
-	
+
 
 	public static function newWith($newsletter, $user) {
 		if(!$user || !$newsletter)
 			return new Class_NewsletterSubscription();
-		
+
 		$subscription = (new Class_NewsletterSubscription())
 			->setNewsletter($newsletter)
 			->setUser($user);
diff --git a/library/Class/Users.php b/library/Class/Users.php
index 41599f619c4217089a8575a035fe28be230f9d87..a8ad8fdea0e95daf4e68ee22625a79187cbb6599 100644
--- a/library/Class/Users.php
+++ b/library/Class/Users.php
@@ -20,10 +20,11 @@
  */
 
 class UsersLoader extends Storm_Model_Loader {
-	public function getNewslettersReceivers($id_newsletter,$recipient_size) {
-		$req = "select bib_admin_users.* from bib_admin_users join newsletters_users on bib_admin_users.id_user = newsletters_users.user_id where newsletter_id = ".$id_newsletter." and newsletters_users.send is false limit ".$recipient_size;
-		$users = Class_Users::findAll($req);
-		return $users;
+ 	public function getNewslettersReceivers($id_newsletter, $recipient_size) {
+		// do not use Storm as it must be ordered by mail AND filtered by joined column
+		$req = 'select bib_admin_users.* from bib_admin_users join newsletters_users on bib_admin_users.id_user = newsletters_users.user_id where newsletter_id = ' . $id_newsletter . ' and newsletters_users.send is false order by bib_admin_users.mail limit ' . $recipient_size;
+
+		return Class_Users::findAll($req);
 	}
 
 
diff --git a/tests/library/Class/MockMailTransport.php b/tests/library/Class/MockMailTransport.php
index 8fd521bf31db1a54f1b9dd2282590239f019ca8f..04d454d6945981512f2e2bbc7585ed415f9c069c 100644
--- a/tests/library/Class/MockMailTransport.php
+++ b/tests/library/Class/MockMailTransport.php
@@ -16,32 +16,32 @@
  *
  * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
  * along with BOKEH; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA 
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
 class MockMailTransport extends Zend_Mail_Transport_Abstract {
 	public $sent_mail = null;
 	protected $_send_block;
-	protected $_sent_mails = array();
+	protected $_sent_mails = [];
 
 	public function send(Zend_Mail $mail) {
 		$this->sent_mail = $mail;
-		$this->_sent_mails []= $mail;
+		$this->_sent_mails[] = $mail;
 
-		if (isset($this->_send_block)) {
+		if (isset($this->_send_block))
 			call_user_func($this->_send_block);
-		}
 	}
 
+
 	public function onSendDo($block) {
 		$this->_send_block = $block;
 	}
 
+
 	protected function _sendMail() {}
 
+
 	public function getSentMails() {
 		return $this->_sent_mails;
 	}
-}
-
-?>
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/tests/library/Class/ModelTestCase.php b/tests/library/Class/ModelTestCase.php
index c90b7e003e9e8f8b70a7e2db071a2dc9bad9612f..ee7985dd2d3678690155c515a6e6871c529ee7f8 100644
--- a/tests/library/Class/ModelTestCase.php
+++ b/tests/library/Class/ModelTestCase.php
@@ -16,7 +16,7 @@
  *
  * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
  * along with BOKEH; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA 
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
 abstract class TestFixtures {
@@ -39,58 +39,69 @@ abstract class TestFixtures {
 abstract class ModelTestCase extends PHPUnit_Framework_TestCase {
 	use Storm_Test_THelpers;
 
+	protected $_registry_sql;
+
+
+
+	protected function setUp() {
+		Storm_Model_Abstract::unsetLoaders();
+		Class_SessionFormationInscription::beVolatile();
+		$this->_registry_sql = Zend_Registry::get('sql');
+	}
+
+
+	protected function tearDown() {
+		Storm_Model_Abstract::unsetLoaders();
+		if ($this->_registry_sql)
+			Zend_Registry::set('sql', $this->_registry_sql);
+	}
+
+
 	protected function _buildTableMock($model, $methods) {
 		$table = $this->getMock('Storm_Model_Table'.$model,$methods);
-		$loader = call_user_func(array($model, 'getLoader'));
+		$loader = call_user_func([$model, 'getLoader']);
 		$loader->setTable($table);
 		return $table;
 	}
 
+
 	protected function _buildRowset($data) {
-		return new Zend_Db_Table_Rowset(array('data' => $data));
+		return new Zend_Db_Table_Rowset(['data' => $data]);
 	}
 
 
 	protected function _setFindExpectation($model, $fixture, $id) {
-		$mock_results = $this->_buildRowset(array($fixture));
+		$mock_results = $this->_buildRowset([$fixture]);
 
-		$this->_buildTableMock($model, array('find'))
+		$this->_buildTableMock($model, ['find'])
 			->expects($this->once())
 			->method('find')
 			->with($id)
 			->will($this->returnValue($mock_results));
 	}
 
-	protected function setUp() {
-		Storm_Model_Abstract::unsetLoaders();
-		Class_SessionFormationInscription::beVolatile();
-	}
-
-	protected function tearDown() {
-		Storm_Model_Abstract::unsetLoaders();
-	}
 
 	protected function _setFindAllExpectation($model, $fixtures) {
 		if (!is_array($fixtures)) {
-			$finst = new $fixtures;
+			$finst = new $fixtures();
 			$fixtures = $finst->all();
 		}
+
 		$mock_results = $this->_buildRowset($fixtures);
-		$tbl_newsletters = $this->_buildTableMock($model,
-																							array('fetchAll'));
+		$tbl_newsletters = $this->_buildTableMock($model, ['fetchAll']);
 
 		$tbl_newsletters
 			->expects($this->once())
 			->method('fetchAll')
 			->will($this->returnValue($mock_results));
+
 		return $tbl_newsletters;
 	}
 
+
 	protected function _generateLoaderFor($model, $methods) {
 		$loader = $this->getMock('Mock'.$model, $methods);
 		Storm_Model_Abstract::setLoaderFor($model, $loader);
 		return $loader;
 	}
-}
-
-?>
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/tests/library/Class/NewsletterMailingTest.php b/tests/library/Class/NewsletterMailingTest.php
index 3cfdefe6b1ffa52371711f6b93c135dd8be2f62b..bd68b5f6febb8f24effeee82050f6a1e4b478543 100644
--- a/tests/library/Class/NewsletterMailingTest.php
+++ b/tests/library/Class/NewsletterMailingTest.php
@@ -33,9 +33,9 @@ abstract class NewsletterMailingTestCase extends ModelTestCase {
 
 		Class_Systeme_TimeLimit::setInstance(
 			$this->mock()
-			->whenCalled('set')->with(30)->answers(null)
-			->whenCalled('reset')->answers(null)
-			->beStrict());
+																				 ->whenCalled('set')->with(30)->answers(null)
+																				 ->whenCalled('reset')->answers(null)
+																				 ->beStrict());
 
 		$profil_portail = $this->fixture('Class_Profil',
 																		 ['id' => 1,
@@ -114,7 +114,7 @@ abstract class NewsletterMailingTestCase extends ModelTestCase {
 			->beStrict();
 
 
-		Storm_Test_ObjectWrapper::onLoaderOfModel('Class_NewsletterSubscription')
+		$this->onLoaderOfModel('Class_NewsletterSubscription')
 			->whenCalled('clearCache')
 			->answers(true)
 
@@ -139,7 +139,7 @@ abstract class NewsletterMailingTestCase extends ModelTestCase {
 
 			->beStrict();
 
-		Storm_Test_ObjectWrapper::onLoaderOfModel('Class_Notice')
+		$this->onLoaderOfModel('Class_Notice')
 			->whenCalled('getNoticesFromPreferences')
 			->willDo(function() {return $this->notices;});
 
@@ -153,30 +153,45 @@ abstract class NewsletterMailingTestCase extends ModelTestCase {
 
 
 	protected function getReceivers() {
-		if ($this->user_model->methodCallCount('getNewslettersReceivers')>1)
+		if ($this->user_model->methodCallCount('getNewslettersReceivers') > 1)
 			 return [];
-		return [$this->rdubois,
-						$this->mduchamp,
-						$this->zork,
-						$this->zorkglub];
+
+		return [$this->rdubois, $this->mduchamp, $this->zork, $this->zorkglub];
+	}
+
+
+	protected function assertMIMEPartContains($needle, $text) {
+		$decoded_text = quoted_printable_decode($text->getContent());
+		$this->assertContains($needle,
+													$decoded_text,
+													$needle . ' not found in ' . $decoded_text);
+	}
+
+
+	protected function assertBodyHTMLContains($needle) {
+		$this->assertMIMEPartContains($needle, $this->mail->getBodyHTML());
+	}
+
+
+	protected function assertBodyTextContains($needle) {
+		$this->assertMIMEPartContains($needle, $this->mail->getBodyText());
 	}
 }
 
 
 
 
-class NewsletterMailingAnimationsTestSendMail extends NewsletterMailingTestCase {
+class NewsletterMailingAnimationsSendMailTest extends NewsletterMailingTestCase {
 	public function setUp() {
 		parent::setup();
-		$this->animations->generateMails(20);
-		$this->mail = $this->mock_transport->sent_mail;
 		$this->batch_send = new Class_Batch_SendNewsletters($this->animations);
 	}
 
 
 	/** @test */
 	public function batchSendParamsShouldBeAsExpected() {
-		$this->assertEquals('"'.$_SERVER['HTTP_HOST'].'" "'.$_SERVER['SERVER_NAME'].'" "'.BASE_URL.'" "1"', $this->batch_send->getExecParams());
+		$this->assertEquals('"'.$_SERVER['HTTP_HOST'].'" "'.$_SERVER['SERVER_NAME'].'" "'.BASE_URL.'" "1"',
+												$this->batch_send->getExecParams());
 	}
 
 
@@ -186,131 +201,257 @@ class NewsletterMailingAnimationsTestSendMail extends NewsletterMailingTestCase
 	}
 
 
-	public function testToArrayContainsAdminPortailAsExpediteur() {
-		$this->assertContains('flo@afi-sa.fr',
-													$this->mail->getFrom());
+	/** @test */
+	public function mailShouldBeSendFromPortalAdmin() {
+		$this->assertContains('flo@afi-sa.fr', $this->mail->getFrom());
 	}
 
-	public function testSubjectIsAnimationsDuMois() {
+
+	/** @test */
+	public function subjectShouldBeAnimationsDuMois() {
 		$this->assertEquals('Animations du mois', $this->mail->getSubject());
 	}
 
-	public function testBodyTextIsDecouverteCuisineDuMonde() {
+
+	/** @test */
+	public function bodyTextShouldBeDecouverteCuisineDuMonde() {
 		$this->assertContains('Découverte des cuisines du monde',
 													quoted_printable_decode($this->mail->getBodyText()->getContent()));
 	}
 
-	public function testBccIncludesRduboisAtFreeDotFr() {
-		$this->assertContains('rdubois@afi-sa.fr',
-													implode(',',$this->mail->getRecipients()));
-	}
 
-	public function testBccIncludesMduchampAtHotmailDotCom() {
-		$this->assertContains('mduchamp@afi-sa.fr',
-													$this->mail->getRecipients());
+	/** @test */
+	public function bccShouldContainsRduboisAtFreeDotFr() {
+		$this->assertContains('rdubois@afi-sa.fr', implode(',',$this->mail->getRecipients()));
 	}
 
 
-	public function testBccShouldNotIncludeZork() {
-		$this->assertNotContains('zork',
-														 $this->mail->getRecipients());
+	/** @test */
+	public function bccShouldContainsMduchampAtHotmailDotCom() {
+		$this->assertContains('mduchamp@afi-sa.fr', $this->mail->getRecipients());
 	}
 
 
-	public function testToIsAdminPortail() {
-		$this->assertContains('flo@afi-sa.fr',
-													$this->mail->getRecipients());
+	/** @test */
+	public function bccShouldNotContainsZork() {
+		$this->assertNotContains('zork', $this->mail->getRecipients());
 	}
 
 
-	public function mailRecipientSizeShouldBe3() {
-		$this->assertEquals(4, count($this->mail->getRecipients()));
+	/** @test */
+	public function recipientsShouldContainsPortalAdmin() {
+		$this->assertContains('flo@afi-sa.fr', $this->mail->getRecipients());
 	}
 
 
-	public function testSenderIsAdminPortail() {
-		$this->assertEquals('flo@afi-sa.fr', $this->mail->getFrom());
+	/** @test */
+	public function mailShouldHave3Recipients() {
+		$this->assertEquals(3, count($this->mail->getRecipients()));
 	}
 
 
-	public function testNewsletterLastDistributionDateIsNow() {
-		$this->assertEquals('23/05/2014 14:30', DateTime::createFromFormat("Y-m-d H:i:s", ($this->animations->getLastDistributionDate()))->format("d/m/Y H:i"));
+	/** @test */
+	public function newsletterLastDistributionDateShouldBeNow() {
+		$this->assertEquals('23/05/2014 14:30',
+												DateTime::createFromFormat('Y-m-d H:i:s',
+																									 ($this->animations->getLastDistributionDate()))
+												->format("d/m/Y H:i"));
 	}
 }
 
 
 
 
-class NewsletterMailingConcertsTestPanier extends NewsletterMailingTestCase {
-	public function setUp() {
-		parent::setup();
-		$this->animations->generateMails(20);
-		$this->mail = $this->mock_transport->sent_mail;
+class NewsletterMailingConcertsPanierTextTest extends NewsletterMailingTestCase {
+	/** @test */
+	public function shouldContainsMillenium() {
+		$this->assertBodyTextContains("Les hommes qui n'aimaient pas les femmes (Stieg Larsson, 2005)");
 	}
 
 
-	public function assertMIMEPartContains($needle, $text) {
-		$decoded_text = quoted_printable_decode($text->getContent());
-		parent::assertContains($needle, $decoded_text,
-													 $needle.' not found in '.$decoded_text);
-	}
-
-	public function assertBodyHTMLContains($needle) {
-		$this->assertMIMEPartContains($needle,
-																	$this->mail->getBodyHTML());
+	/** @test */
+	public function shouldContainsResumeMillenium() {
+		$this->assertBodyTextContains('Polard du nord');
 	}
 
-	public function assertBodyTextContains($needle) {
-		$this->assertMIMEPartContains($needle,
-																	$this->mail->getBodyText());
-	}
 
-	public function testBodyTextContainsMillenium() {
-		$this->assertBodyTextContains("Les hommes qui n'aimaient pas les femmes (Stieg Larsson, 2005)");
-	}
-
-	public function testBodyTextContainsResumeMillenium() {
-		$this->assertBodyTextContains("Polard du nord");
+	/** @test */
+	public function shouldContainsURLMillenium() {
+		$this->assertBodyTextContains('http://localhost' . BASE_URL . '/recherche/viewnotice/id/345');
 	}
 
-	public function testBodyTextContainsURLMillenium() {
-		$this->assertBodyTextContains("http://localhost" . BASE_URL . "/recherche/viewnotice/id/345");
-	}
 
-	public function testBodyTextContainsPotter() {
+	/** @test */
+	public function shouldContainsPotter() {
 		$this->assertBodyTextContains("Harry Potter à l'école des sorciers (J.K. Rowling, 1998)");
 	}
 
-	public function testBodyTextContainsResumePotter() {
+
+	/** @test */
+	public function shouldContainsResumePotter() {
 		$this->assertBodyTextContains("L'histoire d'un sorcier");
 	}
 
-	public function testBodyTextContainsURLPotter() {
-		$this->assertBodyTextContains("http://localhost" . BASE_URL . "/recherche/viewnotice/id/987");
+
+	/** @test */
+	public function shouldContainsURLPotter() {
+		$this->assertBodyTextContains('http://localhost' . BASE_URL . "/recherche/viewnotice/id/987");
 	}
+}
+
+
+
 
-	public function testVignetteMilleniumInHTML() {
+class NewsletterMailingConcertsPanierHtmlTest extends NewsletterMailingTestCase {
+	/** @test */
+	public function shouldContainsVignetteMillenium() {
 		$this->assertBodyHTMLContains('<img src="http://afi-sa.fr/millenium.png"');
 	}
 
-	public function testLinkMillenium() {
+
+	/** @test */
+	public function shouldContainsLinkMillenium() {
 		$this->assertBodyHTMLContains('<a href="http://localhost' . BASE_URL . '/recherche/viewnotice?id=345"');
 	}
 
-	public function testBodyHTMLContainsPotter() {
+
+	/** @test */
+	public function shouldContainsPotter() {
 		$this->assertBodyHTMLContains("Harry Potter à l'école des sorciers (J.K. Rowling, 1998)");
 	}
 
-	public function testBodyHTMLContainsResumePotter() {
+
+	/** @test */
+	public function shouldContainsResumePotter() {
 		$this->assertBodyHTMLContains("L'histoire d'un sorcier...");
 	}
 
-	public function testVignettePotterInHTML() {
+
+	/** @test */
+	public function shouldContainsVignettePotter() {
 		$this->assertBodyHTMLContains('<img src="http://afi-sa.fr/potter.gif"');
 	}
 
-	public function testLinkPotter() {
+
+	/** @test */
+	public function shouldContainsPotterLink() {
 		$this->assertBodyHTMLContains('<a href="http://localhost' . BASE_URL . '/recherche/viewnotice?id=987"');
 	}
 }
-?>
\ No newline at end of file
+
+
+
+/** @see http://forge.afi-sa.fr/issues/18661 */
+class NewsletterMailingDedupTest extends ModelTestCase {
+	protected
+		$_fetch_users_calls = 0,
+		$_letter;
+
+	public function setUp() {
+		parent::setUp();
+		Storm_Model_Loader::defaultToVolatile();
+
+		$this->mock_transport = new MockMailTransport();
+		Zend_Mail::setDefaultTransport($this->mock_transport);
+
+		$time_source = new TimeSourceForTest('2014-05-23 14:30:00');
+		Class_Newsletter::setTimeSource($time_source);
+
+		Class_Systeme_TimeLimit::setInstance($this->mock()
+																				 ->whenCalled('set')->with(30)->answers(null)
+																				 ->whenCalled('reset')->answers(null)
+																				 ->beStrict());
+
+		$this->alcor = $this->fixture('Class_Users',
+																	['id' => 120,
+																	 'login' => 'alc',
+																	 'password' => 'or',
+																	 'mail' => 'procyon@centre-de-recherche.fr']);
+
+		$this->actarus = $this->fixture('Class_Users',
+																		['id' => 121,
+																		 'login' => 'acta',
+																		 'password' => 'rus',
+																		 'mail' => 'procyon@centre-de-recherche.fr']);
+
+		$this->_letter = $this->fixture('Class_Newsletter',
+																		['id' => 23,
+																		 'titre' => 'Alerte vega',
+																		 'id_panier' => 0,
+																		 'id_catalogue' => 0,
+																		 'expediteur' => 'professeur@centre-de-recherche.fr',
+																		 'contenu' => 'Golgoth aperçu azimut 234.53',
+																		 'last_distribution_date' => '',
+																		 'users' => [$this->alcor, $this->actarus]]);
+
+		Zend_Registry::set('sql', $sql = $this->mock());
+		$sql->whenCalled('execute')
+				->with('update newsletters_users set send=false where newsletter_id=23')
+				->answers(null)
+
+				->beStrict();
+
+		$this->onLoaderOfModel('Class_Users');
+	}
+
+
+	public function tearDown() {
+		Storm_Model_Loader::defaultToDb();
+		parent::tearDown();
+	}
+
+
+	/** @test */
+	public function procyonInOnePageShouldNotReceiveTwoMails() {
+		$this->expectUserFetchAndDo(
+																function() {
+																	$this->_fetch_users_calls++;
+																	if (1 == $this->_fetch_users_calls)
+																		return [$this->alcor, $this->actarus];
+
+																	return [];
+																});
+
+		$this->_letter->generateMails(20);
+
+		$this->assertProcyonNotDuplicatedIn($this->mock_transport->getSentMails());
+	}
+
+
+	/** @test */
+	public function procyonInTwoPagesShouldNotReceiveTwoMails() {
+		$this->expectUserFetchAndDo(
+																function() {
+																	$this->_fetch_users_calls++;
+																	if (1 == $this->_fetch_users_calls)
+																		return [$this->alcor];
+
+																	if (2 == $this->_fetch_users_calls)
+																		return [$this->actarus];
+
+																	return [];
+																});
+
+		$this->_letter->generateMails(20);
+
+		$this->assertProcyonNotDuplicatedIn($this->mock_transport->getSentMails());
+	}
+
+
+	protected function assertProcyonNotDuplicatedIn($mails) {
+		$sent = 0;
+		foreach ($mails as $mail)
+			foreach ($mail->getHeaders()['Bcc'] as $recipient)
+				if ('<procyon@centre-de-recherche.fr>' === $recipient)
+					$sent++;
+
+		$this->assertEquals(1, $sent);
+	}
+
+
+	protected function expectUserFetchAndDo($closure) {
+		Class_Users::whenCalled('findAll')
+			->with('select bib_admin_users.* from bib_admin_users join newsletters_users on bib_admin_users.id_user = newsletters_users.user_id where newsletter_id = 23 and newsletters_users.send is false order by bib_admin_users.mail limit 20')
+			->willDo($closure);
+	}
+}
\ No newline at end of file