<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Contao\CoreBundle\Exception\AccessDeniedException;
use Contao\CoreBundle\Exception\ForwardPageNotFoundException;
use Symfony\Component\HttpFoundation\Response;

/**
 * Provide methods to handle an error 403 page.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class PageError403 extends Frontend
{

	/**
	 * Generate an error 403 page
	 *
	 * @param PageModel|integer $objRootPage
	 */
	public function generate($objRootPage=null)
	{
		/** @var PageModel $objPage */
		global $objPage;

		$obj403 = $this->prepare($objRootPage);
		$objPage = $obj403->loadDetails();

		/** @var PageRegular $objHandler */
		$objHandler = new $GLOBALS['TL_PTY']['regular']();

		header('HTTP/1.1 403 Forbidden');
		$objHandler->generate($objPage);
	}

	/**
	 * Return a response object
	 *
	 * @param PageModel|integer $objRootPage
	 *
	 * @return Response
	 */
	public function getResponse($objRootPage=null)
	{
		/** @var PageModel $objPage */
		global $objPage;

		$obj403 = $this->prepare($objRootPage);
		$objPage = $obj403->loadDetails();

		/** @var PageRegular $objHandler */
		$objHandler = new $GLOBALS['TL_PTY']['regular']();

		return $objHandler->getResponse($objPage)->setStatusCode(403);
	}

	/**
	 * Prepare the output
	 *
	 * @param PageModel|integer $objRootPage
	 *
	 * @return PageModel
	 *
	 * @throws AccessDeniedException
	 *
	 * @internal Do not call this method in your code. It will be made private in Contao 5.0.
	 */
	protected function prepare($objRootPage=null)
	{
		// Use the given root page object if available (thanks to Andreas Schempp)
		if ($objRootPage === null)
		{
			$objRootPage = $this->getRootPageFromUrl();
		}
		else
		{
			$objRootPage = PageModel::findPublishedById(\is_int($objRootPage) ? $objRootPage : $objRootPage->id);
		}

		// Look for a 403 page
		$obj403 = PageModel::find403ByPid($objRootPage->id);

		// Die if there is no page at all
		if (null === $obj403)
		{
			throw new AccessDeniedException('Forbidden');
		}

		// Forward to another page
		if ($obj403->autoforward && $obj403->jumpTo)
		{
			$objNextPage = PageModel::findPublishedById($obj403->jumpTo);

			if (null === $objNextPage)
			{
				$this->log('Forward page ID "' . $obj403->jumpTo . '" does not exist', __METHOD__, TL_ERROR);
				throw new ForwardPageNotFoundException('Forward page not found');
			}

			$this->redirect($objNextPage->getFrontendUrl(), (($obj403->redirect == 'temporary') ? 302 : 301));
		}

		return $obj403;
	}
}

class_alias(PageError403::class, 'PageError403');


<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\Security\Voter;

use Contao\BackendUser;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class BackendAccessVoter extends Voter
{
    /**
     * {@inheritdoc}
     */
    protected function supports($attribute, $subject): bool
    {
        return 0 === strpos($attribute, 'contao_user.');
    }

    /**
     * {@inheritdoc}
     */
    protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        [, $field] = explode('.', $attribute, 2);

        if (!$user instanceof BackendUser || (!is_scalar($subject) && !\is_array($subject))) {
            return false;
        }

        return $user->hasAccess($subject, $field);
    }
}


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

/**
 * Provide methods to handle modules of a page layout.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class ModuleWizard extends Widget
{

	/**
	 * Submit user input
	 * @var boolean
	 */
	protected $blnSubmitInput = true;

	/**
	 * Template
	 * @var string
	 */
	protected $strTemplate = 'be_widget';

	/**
	 * Generate the widget and return it as string
	 *
	 * @return string
	 */
	public function generate()
	{
		$this->import(Database::class, 'Database');

		$arrButtons = array('edit', 'copy', 'delete', 'enable', 'drag');

		// Get all modules of the current theme
		$objModules = $this->Database->prepare("SELECT id, name, type FROM tl_module WHERE pid=(SELECT pid FROM " . $this->strTable . " WHERE id=?) ORDER BY name")
									 ->execute($this->currentRecord);

		// Add the articles module
		$modules[] = array('id'=>0, 'name'=>$GLOBALS['TL_LANG']['MOD']['article'][0], 'type'=>'article');

		if ($objModules->numRows)
		{
			$modules = array_merge($modules, $objModules->fetchAllAssoc());
		}

		$GLOBALS['TL_LANG']['FMD']['article'] = $GLOBALS['TL_LANG']['MOD']['article'];

		// Add the module type (see #3835)
		foreach ($modules as $k=>$v)
		{
			$v['type'] = $GLOBALS['TL_LANG']['FMD'][$v['type']][0];
			$modules[$k] = $v;
		}

		$objRow = $this->Database->prepare("SELECT * FROM " . $this->strTable . " WHERE id=?")
								 ->limit(1)
								 ->execute($this->currentRecord);

		// Show all columns and filter in PageRegular (see #3273)
		$cols = array('header', 'left', 'right', 'main', 'footer');
		$positions = array();

		// Add custom layout sections
		if ($objRow->sections != '')
		{
			$arrSections = StringUtil::deserialize($objRow->sections);

			if (!empty($arrSections) && \is_array($arrSections))
			{
				foreach ($arrSections as $v)
				{
					if (!empty($v['id']))
					{
						$cols[] = $v['id'];
						$positions[$v['id']] = $v['position'];
					}
				}
			}
		}

		$cols = Backend::convertLayoutSectionIdsToAssociativeArray($cols);

		// Get the new value
		if (Input::post('FORM_SUBMIT') == $this->strTable)
		{
			$this->varValue = Input::post($this->strId);
		}

		// Make sure there is at least an empty array
		if (!\is_array($this->varValue) || !$this->varValue[0])
		{
			$this->varValue = array(array('mod'=>0, 'col'=>'main'));
		}
		else
		{
			// Initialize the sorting order
			$arrCols = array
			(
				'top' => array(),
				'header' => array(),
				'before' => array(),
				'left' => array(),
				'right' => array(),
				'main' => array(),
				'after' => array(),
				'footer' => array(),
				'bottom' => array(),
				'manual' => array()
			);

			foreach ($this->varValue as $v)
			{
				$key = $v['col'];

				if (isset($positions[$v['col']]))
				{
					$key = $positions[$v['col']];
				}

				$arrCols[$key][] = $v;
			}

			$this->varValue = array_merge(...array_values($arrCols));
		}

		// Add the label and the return wizard
		$return = '
strId.'" class="tl_modulewizard">

    '.$GLOBALS['TL_LANG']['MSC']['mw_module'].'
    '.$GLOBALS['TL_LANG']['MSC']['mw_column'].'

';

		// Add the input fields
		for ($i=0, $c=\count($this->varValue); $i<$c; $i++)
		{
			$options = '';

			// Add modules
			foreach ($modules as $v)
			{
				$options .= '<option value="'.StringUtil::specialchars($v['id']).'"'.static::optionSelected($v['id'], $this->varValue[$i]['mod']).'>'.$v['name'].' ['. $v['type'] .']</option>';
			}

			$return .= '

    <select name="'.$this->strId.'['.$i.'][mod]" class="tl_select tl_chosen" onfocus="Backend.getScrollOffset()" onchange="Backend.updateModuleLink(this)">'.$options.'</select>	';

			$options = '';

			// Add columns
			foreach ($cols as $k=>$v)
			{
				$options .= '<option value="'.StringUtil::specialchars($k).'"'.static::optionSelected($k, $this->varValue[$i]['col']).'>'.$v.'</option>';
			}

			$return .= '
    <select name="'.$this->strId.'['.$i.'][col]" class="tl_select_column" onfocus="Backend.getScrollOffset()">'.$options.'</select>
    ';

			// Add buttons
			foreach ($arrButtons as $button)
			{
				if ($button == 'edit')
				{
					$return .= ' varValue[$i]['mod'] . '&popup=1&nb=1&rt=' . REQUEST_TOKEN . '" title="' . StringUtil::specialchars($GLOBALS['TL_LANG']['tl_layout']['edit_module']) . '" class="module_link" ' . (($this->varValue[$i]['mod'] > 0) ? '' : ' style="display:none"') . ' onclick="Backend.openModalIframe({\'title\':\'' . StringUtil::specialchars(str_replace("'", "\\'", $GLOBALS['TL_LANG']['tl_layout']['edit_module'])) . '\',\'url\':this.href});return false">'.Image::getHtml('edit.svg').'' . Image::getHtml('edit_.svg', '', 'class="module_image"' . (($this->varValue[$i]['mod'] > 0) ? ' style="display:none"' : ''));
				}
				elseif ($button == 'drag')
				{
					$return .= ' <button type="button" class="drag-handle" title="' . StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['move']) . '" aria-hidden="true">' . Image::getHtml('drag.svg') . '</button>';
				}
				elseif ($button == 'enable')
				{
					$return .= ' <button type="button" data-command="enable" class="mw_enable" title="' . StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['mw_enable']) . '">' . Image::getHtml((($this->varValue[$i]['enable']) ? 'visible.svg' : 'invisible.svg')) . '</button><input name="'.$this->strId.'['.$i.'][enable]" type="checkbox" class="tl_checkbox mw_enable" value="1" onfocus="Backend.getScrollOffset()"'. (($this->varValue[$i]['enable']) ? ' checked' : '').'>';
				}
				else
				{
					$return .= ' <button type="button" data-command="' . $button . '" title="' . StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['mw_'.$button]) . '">' . Image::getHtml($button.'.svg') . '</button>';
				}
			}

			$return .= '

';
		}

		return $return.'

  ';
	}
}

class_alias(ModuleWizard::class, 'ModuleWizard');


<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\Security\Authentication\Provider;

use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\CoreBundle\Security\Exception\LockedException;
use Contao\System;
use Contao\User;
use Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class AuthenticationProvider extends DaoAuthenticationProvider
{
    /**
     * @var ContaoFramework
     */
    private $framework;

    /**
     * @var array
     */
    private $options;

    public function __construct(UserProviderInterface $userProvider, UserCheckerInterface $userChecker, string $providerKey, EncoderFactoryInterface $encoderFactory, ContaoFramework $framework, array $options = [])
    {
        parent::__construct($userProvider, $userChecker, $providerKey, $encoderFactory, false);

        $this->framework = $framework;
        $this->options = array_merge(['login_attempts' => 3, 'lock_period' => 300], $options);
    }

    /**
     * {@inheritdoc}
     */
    public function checkAuthentication(UserInterface $user, UsernamePasswordToken $token): void
    {
        if (!$user instanceof User) {
            parent::checkAuthentication($user, $token);

            return;
        }

        try {
            parent::checkAuthentication($user, $token);
        } catch (AuthenticationException $exception) {
            if (!$exception instanceof BadCredentialsException) {
                throw $exception;
            }

            if (!$this->triggerCheckCredentialsHook($user, $token)) {
                throw $this->onBadCredentials($user, $exception);
            }
        }

        $user->loginCount = $this->options['login_attempts'];
        $user->save();
    }

    /**
     * Counts the login attempts and locks the user if it reaches zero.
     */
    public function onBadCredentials(User $user, AuthenticationException $exception): AuthenticationException
    {
        --$user->loginCount;

        if ($user->loginCount > 0) {
            $user->save();

            return new BadCredentialsException(
                sprintf('Invalid password submitted for username "%s"', $user->username),
                $exception->getCode(),
                $exception
            );
        }

        $user->locked = time() + $this->options['lock_period'];
        $user->loginCount = $this->options['login_attempts'];
        $user->save();

        $lockedSeconds = $user->locked - time();
        $lockedMinutes = (int) ceil($lockedSeconds / 60);

        $exception = new LockedException(
            $lockedSeconds,
            sprintf('User "%s" has been locked for %s minutes', $user->username, $lockedMinutes),
            0,
            $exception
        );

        $exception->setUser($user);

        return $exception;
    }

    private function triggerCheckCredentialsHook(User $user, UsernamePasswordToken $token): bool
    {
        $this->framework->initialize();

        if (empty($GLOBALS['TL_HOOKS']['checkCredentials']) || !\is_array($GLOBALS['TL_HOOKS']['checkCredentials'])) {
            return false;
        }

        @trigger_error('Using the checkCredentials hook has been deprecated and will no longer work in Contao 5.0.', E_USER_DEPRECATED);

        /** @var System $system */
        $system = $this->framework->getAdapter(System::class);
        $username = $token->getUsername();
        $credentials = $token->getCredentials();

        foreach ($GLOBALS['TL_HOOKS']['checkCredentials'] as $callback) {
            if ($system->importStatic($callback[0])->{$callback[1]}($username, $credentials, $user)) {
                return true;
            }
        }

        return false;
    }
}


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ControllerReference;
use Symfony\Component\HttpKernel\Fragment\FragmentHandler;

/**
 * A static class to replace insert tags
 *
 * Usage:
 *
 *     $it = new InsertTags();
 *     echo $it->replace($text);
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class InsertTags extends Controller
{

	/**
	 * @var array
	 */
	protected static $arrItCache = array();

	/**
	 * Make the constructor public
	 */
	public function __construct()
	{
		parent::__construct();
	}

	/**
	 * Recursively replace insert tags with their values
	 *
	 * @param string  $strBuffer The text with the tags to be replaced
	 * @param boolean $blnCache  If false, non-cacheable tags will be replaced
	 *
	 * @return string The text with the replaced tags
	 */
	public function replace($strBuffer, $blnCache=true)
	{
		$strBuffer = $this->doReplace($strBuffer, $blnCache);

		// Run the replacement recursively (see #8172)
		while (strpos($strBuffer, '{{') !== false && ($strTmp = $this->doReplace($strBuffer, $blnCache)) != $strBuffer)
		{
			$strBuffer = $strTmp;
		}

		return $strBuffer;
	}

	/**
	 * Reset the insert tag cache
	 */
	public static function reset()
	{
		static::$arrItCache = array();
	}

	/**
	 * Replace insert tags with their values
	 *
	 * @param string  $strBuffer The text with the tags to be replaced
	 * @param boolean $blnCache  If false, non-cacheable tags will be replaced
	 *
	 * @return string The text with the replaced tags
	 */
	protected function doReplace($strBuffer, $blnCache)
	{
		/** @var PageModel $objPage */
		global $objPage;

		// Preserve insert tags
		if (Config::get('disableInsertTags'))
		{
			return StringUtil::restoreBasicEntities($strBuffer);
		}

		// The first letter must not be a reserved character of Twig, Mustache or similar template engines (see #805)
		$tags = preg_split('~{{([a-zA-Z0-9\x80-\xFF][^{}]*)}}~', $strBuffer, -1, PREG_SPLIT_DELIM_CAPTURE);

		if (\count($tags) < 2)
		{
			return StringUtil::restoreBasicEntities($strBuffer);
		}

		$strBuffer = '';
		$container = System::getContainer();

		// Create one cache per cache setting (see #7700)
		$arrCache = &static::$arrItCache[$blnCache];

		for ($_rit=0, $_cnt=\count($tags); $_rit<$_cnt; $_rit+=2)
		{
			$strBuffer .= $tags[$_rit];
			$strTag = $tags[$_rit+1];

			// Skip empty tags
			if ($strTag == '')
			{
				continue;
			}

			$flags = explode('|', $strTag);
			$tag = array_shift($flags);
			$elements = explode('::', $tag);

			// Load the value from cache
			if (isset($arrCache[$strTag]) && $elements[0] != 'page' && !\in_array('refresh', $flags))
			{
				$strBuffer .= $arrCache[$strTag];
				continue;
			}

			// Skip certain elements if the output will be cached
			if ($blnCache)
			{
				if ($elements[0] == 'date' || $elements[0] == 'ua' || $elements[0] == 'post' || $elements[1] == 'back' || $elements[1] == 'referer' || $elements[0] == 'request_token' || strncmp($elements[0], 'cache_', 6) === 0 || \in_array('uncached', $flags))
				{
					/** @var FragmentHandler $fragmentHandler */
					$fragmentHandler = $container->get('fragment.handler');

					$attributes = array('insertTag' => '{{' . $strTag . '}}');

					/** @var Request|null $request */
					$request = $container->get('request_stack')->getCurrentRequest();

					if (null !== $request && ($scope = $request->attributes->get('_scope')))
					{
						$attributes['_scope'] = $scope;
					}

					$strBuffer .= $fragmentHandler->render(
						new ControllerReference(
							'contao.controller.insert_tags:renderAction',
							$attributes,
							array('clientCache' => (int) $objPage->clientCache, 'pageId' => $objPage->id, 'request' => Environment::get('request'))
						),
						'esi',
						array('ignore_errors'=>false) // see #48
					);

					continue;
				}
			}

			$arrCache[$strTag] = '';

			// Replace the tag
			switch (strtolower($elements[0]))
			{
				// Date
				case 'date':
					$arrCache[$strTag] = Date::parse($elements[1] ?: Config::get('dateFormat'));
					break;

				// Accessibility tags
				case 'lang':
					if ($elements[1] == '')
					{
						$arrCache[$strTag] = '';
					}
					else
					{
						$arrCache[$strTag] = $arrCache[$strTag] = '';
					}
					break;

				// Line break
				case 'br':
					$arrCache[$strTag] = '
';
					break;

				// E-mail addresses
				case 'email':
				case 'email_open':
				case 'email_url':
					if ($elements[1] == '')
					{
						$arrCache[$strTag] = '';
						break;
					}

					$strEmail = StringUtil::encodeEmail($elements[1]);

					// Replace the tag
					switch (strtolower($elements[0]))
					{
						case 'email':
							$arrCache[$strTag] = '' . preg_replace('/\?.*$/', '', $strEmail) . '';
							break;

						case 'email_open':
							$arrCache[$strTag] = '';
							break;

						case 'email_url':
							$arrCache[$strTag] = $strEmail;
							break;
					}
					break;

				// Label tags
				case 'label':
					$keys = explode(':', $elements[1]);

					if (\count($keys) < 2)
					{
						$arrCache[$strTag] = '';
						break;
					}

					$file = $keys[0];

					// Map the key (see #7217)
					switch ($file)
					{
						case 'CNT':
							$file = 'countries';
							break;

						case 'LNG':
							$file = 'languages';
							break;

						case 'MOD':
						case 'FMD':
							$file = 'modules';
							break;

						case 'FFL':
							$file = 'tl_form_field';
							break;

						case 'CACHE':
							$file = 'tl_page';
							break;

						case 'XPL':
							$file = 'explain';
							break;

						case 'XPT':
							$file = 'exception';
							break;

						case 'MSC':
						case 'ERR':
						case 'CTE':
						case 'PTY':
						case 'FOP':
						case 'CHMOD':
						case 'DAYS':
						case 'MONTHS':
						case 'UNITS':
						case 'CONFIRM':
						case 'DP':
						case 'COLS':
							$file = 'default';
							break;
					}

					System::loadLanguageFile($file);

					if (\count($keys) == 2)
					{
						$arrCache[$strTag] = $GLOBALS['TL_LANG'][$keys[0]][$keys[1]];
					}
					else
					{
						$arrCache[$strTag] = $GLOBALS['TL_LANG'][$keys[0]][$keys[1]][$keys[2]];
					}
					break;

				// Front end user
				case 'user':
					if (FE_USER_LOGGED_IN)
					{
						$this->import(FrontendUser::class, 'User');
						$value = $this->User->{$elements[1]};

						if ($value == '')
						{
							$arrCache[$strTag] = $value;
							break;
						}

						$this->loadDataContainer('tl_member');

						if ($GLOBALS['TL_DCA']['tl_member']['fields'][$elements[1]]['inputType'] == 'password')
						{
							$arrCache[$strTag] = '';
							break;
						}

						$value = StringUtil::deserialize($value);

						// Decrypt the value
						if ($GLOBALS['TL_DCA']['tl_member']['fields'][$elements[1]]['eval']['encrypt'])
						{
							$value = Encryption::decrypt($value);
						}

						$rgxp = $GLOBALS['TL_DCA']['tl_member']['fields'][$elements[1]]['eval']['rgxp'];
						$opts = $GLOBALS['TL_DCA']['tl_member']['fields'][$elements[1]]['options'];
						$rfrc = $GLOBALS['TL_DCA']['tl_member']['fields'][$elements[1]]['reference'];

						if ($rgxp == 'date')
						{
							$arrCache[$strTag] = Date::parse(Config::get('dateFormat'), $value);
						}
						elseif ($rgxp == 'time')
						{
							$arrCache[$strTag] = Date::parse(Config::get('timeFormat'), $value);
						}
						elseif ($rgxp == 'datim')
						{
							$arrCache[$strTag] = Date::parse(Config::get('datimFormat'), $value);
						}
						elseif (\is_array($value))
						{
							$arrCache[$strTag] = implode(', ', $value);
						}
						elseif (\is_array($opts) && array_is_assoc($opts))
						{
							$arrCache[$strTag] = $opts[$value] ?? $value;
						}
						elseif (\is_array($rfrc))
						{
							$arrCache[$strTag] = isset($rfrc[$value]) ? ((\is_array($rfrc[$value])) ? $rfrc[$value][0] : $rfrc[$value]) : $value;
						}
						else
						{
							$arrCache[$strTag] = $value;
						}

						// Convert special characters (see #1890)
						$arrCache[$strTag] = StringUtil::specialchars($arrCache[$strTag]);
					}
					break;

				// Link
				case 'link':
				case 'link_open':
				case 'link_url':
				case 'link_title':
				case 'link_target':
				case 'link_name':
					$strTarget = null;

					// Back link
					if ($elements[1] == 'back')
					{
						$strUrl = 'javascript:history.go(-1)';
						$strTitle = $GLOBALS['TL_LANG']['MSC']['goBack'];

						// No language files if the page is cached
						if (!\strlen($strTitle))
						{
							$strTitle = 'Go back';
						}

						$strName = $strTitle;
					}

					// External links
					elseif (strncmp($elements[1], 'http://', 7) === 0 || strncmp($elements[1], 'https://', 8) === 0)
					{
						$strUrl = $elements[1];
						$strTitle = $elements[1];
						$strName = str_replace(array('http://', 'https://'), '', $elements[1]);
					}

					// Regular link
					else
					{
						// User login page
						if ($elements[1] == 'login')
						{
							if (!FE_USER_LOGGED_IN)
							{
								break;
							}

							$this->import(FrontendUser::class, 'User');
							$elements[1] = $this->User->loginPage;
						}

						$objNextPage = PageModel::findByIdOrAlias($elements[1]);

						if ($objNextPage === null)
						{
							// Prevent broken markup with link_open and link_close (see #92)
							if (strtolower($elements[0]) == 'link_open')
							{
								$arrCache[$strTag] = '';
							}

							break;
						}

						// Page type specific settings (thanks to Andreas Schempp)
						switch ($objNextPage->type)
						{
							case 'redirect':
								$strUrl = $objNextPage->url;

								if (strncasecmp($strUrl, 'mailto:', 7) === 0)
								{
									$strUrl = StringUtil::encodeEmail($strUrl);
								}
								break;

							case 'forward':
								if ($objNextPage->jumpTo)
								{
									$objNext = PageModel::findPublishedById($objNextPage->jumpTo);
								}
								else
								{
									$objNext = PageModel::findFirstPublishedRegularByPid($objNextPage->id);
								}

								if ($objNext instanceof PageModel)
								{
									$strUrl = \in_array('absolute', $flags, true) ? $objNext->getAbsoluteUrl() : $objNext->getFrontendUrl();
									break;
								}
								// DO NOT ADD A break; STATEMENT

							default:
								$strUrl = \in_array('absolute', $flags, true) ? $objNextPage->getAbsoluteUrl() : $objNextPage->getFrontendUrl();
								break;
						}

						$strName = $objNextPage->title;
						$strTarget = $objNextPage->target ? ' target="_blank"' : '';
						$strTitle = $objNextPage->pageTitle ?: $objNextPage->title;
					}

					// Replace the tag
					switch (strtolower($elements[0]))
					{
						case 'link':
							$arrCache[$strTag] = sprintf('%s', $strUrl, StringUtil::specialchars($strTitle), $strTarget, $strName);
							break;

						case 'link_open':
							$arrCache[$strTag] = sprintf('', $strUrl, StringUtil::specialchars($strTitle), $strTarget);
							break;

						case 'link_url':
							$arrCache[$strTag] = $strUrl;
							break;

						case 'link_title':
							$arrCache[$strTag] = StringUtil::specialchars($strTitle);
							break;

						case 'link_target':
							$arrCache[$strTag] = $strTarget;
							break;

						case 'link_name':
							$arrCache[$strTag] = $strName;
							break;
					}
					break;

				// Closing link tag
				case 'link_close':
				case 'email_close':
					$arrCache[$strTag] = '';
					break;

				// Insert article
				case 'insert_article':
					if (($strOutput = $this->getArticle($elements[1], false, true)) !== false)
					{
						$arrCache[$strTag] = ltrim($strOutput);
					}
					else
					{
						$arrCache[$strTag] = '
' . sprintf($GLOBALS['TL_LANG']['MSC']['invalidPage'], $elements[1]) . '
';
					}
					break;

				// Insert content element
				case 'insert_content':
					$arrCache[$strTag] = $this->getContentElement($elements[1]);
					break;

				// Insert module
				case 'insert_module':
					$arrCache[$strTag] = $this->getFrontendModule($elements[1]);
					break;

				// Insert form
				case 'insert_form':
					$arrCache[$strTag] = $this->getForm($elements[1]);
					break;

				// Article
				case 'article':
				case 'article_open':
				case 'article_url':
				case 'article_title':
					if (!(($objArticle = ArticleModel::findByIdOrAlias($elements[1])) instanceof ArticleModel) || !(($objPid = $objArticle->getRelated('pid')) instanceof PageModel))
					{
						break;
					}

					/** @var PageModel $objPid */
					$params = '/articles/' . ($objArticle->alias ?: $objArticle->id);
					$strUrl = \in_array('absolute', $flags, true) ? $objPid->getAbsoluteUrl($params) : $objPid->getFrontendUrl($params);

					// Replace the tag
					switch (strtolower($elements[0]))
					{
						case 'article':
							$arrCache[$strTag] = sprintf('%s', $strUrl, StringUtil::specialchars($objArticle->title), $objArticle->title);
							break;

						case 'article_open':
							$arrCache[$strTag] = sprintf('', $strUrl, StringUtil::specialchars($objArticle->title));
							break;

						case 'article_url':
							$arrCache[$strTag] = $strUrl;
							break;

						case 'article_title':
							$arrCache[$strTag] = StringUtil::specialchars($objArticle->title);
							break;
					}
					break;

				// Article teaser
				case 'article_teaser':
					$objTeaser = ArticleModel::findByIdOrAlias($elements[1]);

					if ($objTeaser !== null)
					{
						$arrCache[$strTag] = StringUtil::toHtml5($objTeaser->teaser);
					}
					break;

				// Last update
				case 'last_update':
					$strQuery = "SELECT MAX(tstamp) AS tc";
					$bundles = $container->getParameter('kernel.bundles');

					if (isset($bundles['ContaoNewsBundle']))
					{
						$strQuery .= ", (SELECT MAX(tstamp) FROM tl_news) AS tn";
					}

					if (isset($bundles['ContaoCalendarBundle']))
					{
						$strQuery .= ", (SELECT MAX(tstamp) FROM tl_calendar_events) AS te";
					}

					$strQuery .= " FROM tl_content";
					$objUpdate = Database::getInstance()->query($strQuery);

					if ($objUpdate->numRows)
					{
						$arrCache[$strTag] = Date::parse($elements[1] ?: Config::get('datimFormat'), max($objUpdate->tc, $objUpdate->tn, $objUpdate->te));
					}
					break;

				// Version
				case 'version':
					$arrCache[$strTag] = VERSION . '.' . BUILD;
					break;

				// Request token
				case 'request_token':
					$arrCache[$strTag] = REQUEST_TOKEN;
					break;

				// POST data
				case 'post':
					$arrCache[$strTag] = Input::post($elements[1]);
					break;

				// Conditional tags (if)
				case 'iflng':
					if ($elements[1] != '')
					{
						$langs = StringUtil::trimsplit(',', $elements[1]);

						// Check if there are wildcards (see #8313)
						foreach ($langs as $k=>$v)
						{
							if (substr($v, -1) == '*')
							{
								$langs[$k] = substr($v, 0, -1);

								if (\strlen($objPage->language) > 2 && substr($objPage->language, 0, 2) == $langs[$k])
								{
									$langs[] = $objPage->language;
								}
							}
						}

						if (!\in_array($objPage->language, $langs))
						{
							for (; $_rit<$_cnt; $_rit+=2)
							{
								if ($tags[$_rit+1] == 'iflng' || $tags[$_rit+1] == 'iflng::' . $objPage->language)
								{
									break;
								}
							}
						}
					}
					unset($arrCache[$strTag]);
					break;

				// Conditional tags (if not)
				case 'ifnlng':
					if ($elements[1] != '')
					{
						$langs = StringUtil::trimsplit(',', $elements[1]);

						// Check if there are wildcards (see #8313)
						foreach ($langs as $k=>$v)
						{
							if (substr($v, -1) == '*')
							{
								$langs[$k] = substr($v, 0, -1);

								if (\strlen($objPage->language) > 2 && substr($objPage->language, 0, 2) == $langs[$k])
								{
									$langs[] = $objPage->language;
								}
							}
						}

						if (\in_array($objPage->language, $langs))
						{
							for (; $_rit<$_cnt; $_rit+=2)
							{
								if ($tags[$_rit+1] == 'ifnlng')
								{
									break;
								}
							}
						}
					}
					unset($arrCache[$strTag]);
					break;

				// Environment
				case 'env':
					switch ($elements[1])
					{
						case 'host':
							$arrCache[$strTag] = Idna::decode(Environment::get('host'));
							break;

						case 'http_host':
							$arrCache[$strTag] = Idna::decode(Environment::get('httpHost'));
							break;

						case 'url':
							$arrCache[$strTag] = Idna::decode(Environment::get('url'));
							break;

						case 'path':
							$arrCache[$strTag] = Idna::decode(Environment::get('base'));
							break;

						case 'request':
							$arrCache[$strTag] = Environment::get('indexFreeRequest');
							break;

						case 'ip':
							$arrCache[$strTag] = Environment::get('ip');
							break;

						case 'referer':
							$arrCache[$strTag] = $this->getReferer(true);
							break;

						case 'files_url':
							$arrCache[$strTag] = $container->get('contao.assets.files_context')->getStaticUrl();
							break;

						case 'assets_url':
						case 'plugins_url':
						case 'script_url':
							$arrCache[$strTag] = $container->get('contao.assets.assets_context')->getStaticUrl();
							break;

						case 'base_url':
							$arrCache[$strTag] = $container->get('request_stack')->getCurrentRequest()->getBaseUrl();
							break;
					}
					break;

				// Page
				case 'page':
					if ($elements[1] == 'pageTitle' && $objPage->pageTitle == '')
					{
						$elements[1] = 'title';
					}
					elseif ($elements[1] == 'parentPageTitle' && $objPage->parentPageTitle == '')
					{
						$elements[1] = 'parentTitle';
					}
					elseif ($elements[1] == 'mainPageTitle' && $objPage->mainPageTitle == '')
					{
						$elements[1] = 'mainTitle';
					}

					// Do not use StringUtil::specialchars() here (see #4687)
					$arrCache[$strTag] = $objPage->{$elements[1]};
					break;

				// User agent
				case 'ua':
					$ua = Environment::get('agent');

					if ($elements[1] != '')
					{
						$arrCache[$strTag] = $ua->{$elements[1]};
					}
					else
					{
						$arrCache[$strTag] = '';
					}
					break;

				// Abbreviations
				case 'abbr':
				case 'acronym':
					if ($elements[1] != '')
					{
						$arrCache[$strTag] = '';
					}
					else
					{
						$arrCache[$strTag] = '';
					}
					break;

				// Images
				case 'image':
				case 'picture':
					$width = null;
					$height = null;
					$alt = '';
					$class = '';
					$rel = '';
					$strFile = $elements[1];
					$mode = '';
					$size = null;
					$strTemplate = 'picture_default';

					// Take arguments
					if (strpos($elements[1], '?') !== false)
					{
						$arrChunks = explode('?', urldecode($elements[1]), 2);
						$strSource = StringUtil::decodeEntities($arrChunks[1]);
						$strSource = str_replace('[&]', '&', $strSource);
						$arrParams = explode('&', $strSource);

						foreach ($arrParams as $strParam)
						{
							list($key, $value) = explode('=', $strParam);

							switch ($key)
							{
								case 'width':
									$width = $value;
									break;

								case 'height':
									$height = $value;
									break;

								case 'alt':
									$alt = $value;
									break;

								case 'class':
									$class = $value;
									break;

								case 'rel':
									$rel = $value;
									break;

								case 'mode':
									$mode = $value;
									break;

								case 'size':
									$size = (int) $value;
									break;

								case 'template':
									$strTemplate = preg_replace('/[^a-z0-9_]/i', '', $value);
									break;
							}
						}

						$strFile = $arrChunks[0];
					}

					if (Validator::isUuid($strFile))
					{
						// Handle UUIDs
						$objFile = FilesModel::findByUuid($strFile);

						if ($objFile === null)
						{
							$arrCache[$strTag] = '';
							break;
						}

						$strFile = $objFile->path;
					}
					elseif (is_numeric($strFile))
					{
						// Handle numeric IDs (see #4805)
						$objFile = FilesModel::findByPk($strFile);

						if ($objFile === null)
						{
							$arrCache[$strTag] = '';
							break;
						}

						$strFile = $objFile->path;
					}
					else
					{
						// Check the path
						if (Validator::isInsecurePath($strFile))
						{
							throw new \RuntimeException('Invalid path ' . $strFile);
						}
					}

					// Check the maximum image width
					if (Config::get('maxImageWidth') > 0 && $width > Config::get('maxImageWidth'))
					{
						@trigger_error('Using a maximum front end width has been deprecated and will no longer work in Contao 5.0. Remove the "maxImageWidth" configuration and use responsive images instead.', E_USER_DEPRECATED);

						$width = Config::get('maxImageWidth');
						$height = null;
					}

					// Use the alternative text from the image meta data if none is given
					if (!$alt && ($objFile = FilesModel::findByPath($strFile)))
					{
						$arrMeta = Frontend::getMetaData($objFile->meta, $objPage->language);

						if (isset($arrMeta['alt']))
						{
							$alt = $arrMeta['alt'];
						}
					}

					// Generate the thumbnail image
					try
					{
						// Image
						if (strtolower($elements[0]) == 'image')
						{
							$dimensions = '';
							$src = $container->get('contao.image.image_factory')->create($container->getParameter('kernel.project_dir') . '/' . rawurldecode($strFile), array($width, $height, $mode))->getUrl($container->getParameter('kernel.project_dir'));
							$objFile = new File(rawurldecode($src));

							// Add the image dimensions
							if (($imgSize = $objFile->imageSize) !== false)
							{
								$dimensions = ' width="' . StringUtil::specialchars($imgSize[0]) . '" height="' . StringUtil::specialchars($imgSize[1]) . '"';
							}

							$arrCache[$strTag] = '';
						}

						// Picture
						else
						{
							$staticUrl = $container->get('contao.assets.files_context')->getStaticUrl();
							$picture = $container->get('contao.image.picture_factory')->create($container->getParameter('kernel.project_dir') . '/' . $strFile, $size);

							$picture = array
							(
								'img' => $picture->getImg($container->getParameter('kernel.project_dir'), $staticUrl),
								'sources' => $picture->getSources($container->getParameter('kernel.project_dir'), $staticUrl)
							);

							$picture['alt'] = $alt;
							$picture['class'] = $class;
							$pictureTemplate = new FrontendTemplate($strTemplate);
							$pictureTemplate->setData($picture);
							$arrCache[$strTag] = $pictureTemplate->parse();
						}

						// Add a lightbox link
						if ($rel != '')
						{
							$arrCache[$strTag] = '' . $arrCache[$strTag] . '';
						}
					}
					catch (\Exception $e)
					{
						$arrCache[$strTag] = '';
					}
					break;

				// Files (UUID or template path)
				case 'file':
					if (Validator::isUuid($elements[1]))
					{
						$objFile = FilesModel::findByUuid($elements[1]);

						if ($objFile !== null)
						{
							$arrCache[$strTag] = $objFile->path;
							break;
						}
					}

					$arrGet = $_GET;
					Input::resetCache();
					$strFile = $elements[1];

					// Take arguments and add them to the $_GET array
					if (strpos($elements[1], '?') !== false)
					{
						$arrChunks = explode('?', urldecode($elements[1]));
						$strSource = StringUtil::decodeEntities($arrChunks[1]);
						$strSource = str_replace('[&]', '&', $strSource);
						$arrParams = explode('&', $strSource);

						foreach ($arrParams as $strParam)
						{
							$arrParam = explode('=', $strParam);
							$_GET[$arrParam[0]] = $arrParam[1];
						}

						$strFile = $arrChunks[0];
					}

					// Check the path
					if (Validator::isInsecurePath($strFile))
					{
						throw new \RuntimeException('Invalid path ' . $strFile);
					}

					// Include .php, .tpl, .xhtml and .html5 files
					if (preg_match('/\.(php|tpl|xhtml|html5)$/', $strFile) && file_exists($container->getParameter('kernel.project_dir') . '/templates/' . $strFile))
					{
						ob_start();

						try
						{
							include $container->getParameter('kernel.project_dir') . '/templates/' . $strFile;
							$arrCache[$strTag] = ob_get_contents();
						}
						finally
						{
							ob_end_clean();
						}
					}

					$_GET = $arrGet;
					Input::resetCache();
					break;

				// HOOK: pass unknown tags to callback functions
				default:
					if (isset($GLOBALS['TL_HOOKS']['replaceInsertTags']) && \is_array($GLOBALS['TL_HOOKS']['replaceInsertTags']))
					{
						foreach ($GLOBALS['TL_HOOKS']['replaceInsertTags'] as $callback)
						{
							$this->import($callback[0]);
							$varValue = $this->{$callback[0]}->{$callback[1]}($tag, $blnCache, $arrCache[$strTag], $flags, $tags, $arrCache, $_rit, $_cnt); // see #6672

							// Replace the tag and stop the loop
							if ($varValue !== false)
							{
								$arrCache[$strTag] = $varValue;
								break 2;
							}
						}
					}

					$this->log('Unknown insert tag {{' . $strTag . '}}', __METHOD__, TL_ERROR);
					break;
			}

			// Handle the flags
			if (!empty($flags))
			{
				foreach ($flags as $flag)
				{
					switch ($flag)
					{
						case 'addslashes':
						case 'standardize':
						case 'ampersand':
						case 'specialchars':
						case 'nl2br':
						case 'nl2br_pre':
						case 'strtolower':
						case 'utf8_strtolower':
						case 'strtoupper':
						case 'utf8_strtoupper':
						case 'ucfirst':
						case 'lcfirst':
						case 'ucwords':
						case 'trim':
						case 'rtrim':
						case 'ltrim':
						case 'utf8_romanize':
						case 'urlencode':
						case 'rawurlencode':
							$arrCache[$strTag] = $flag($arrCache[$strTag]);
							break;

						case 'encodeEmail':
							$arrCache[$strTag] = StringUtil::$flag($arrCache[$strTag]);
							break;

						case 'number_format':
							$arrCache[$strTag] = System::getFormattedNumber($arrCache[$strTag], 0);
							break;

						case 'currency_format':
							$arrCache[$strTag] = System::getFormattedNumber($arrCache[$strTag], 2);
							break;

						case 'readable_size':
							$arrCache[$strTag] = System::getReadableSize($arrCache[$strTag]);
							break;

						case 'flatten':
							if (!\is_array($arrCache[$strTag]))
							{
								break;
							}

							$it = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($arrCache[$strTag]));
							$result = array();

							foreach ($it as $leafValue)
							{
								$keys = array();

								foreach (range(0, $it->getDepth()) as $depth)
								{
									$keys[] = $it->getSubIterator($depth)->key();
								}

								$result[] = implode('.', $keys) . ': ' . $leafValue;
							}

							$arrCache[$strTag] = implode(', ', $result);
							break;

						case 'refresh':
						case 'uncached':
							// ignore
							break;

						// HOOK: pass unknown flags to callback functions
						default:
							if (isset($GLOBALS['TL_HOOKS']['insertTagFlags']) && \is_array($GLOBALS['TL_HOOKS']['insertTagFlags']))
							{
								foreach ($GLOBALS['TL_HOOKS']['insertTagFlags'] as $callback)
								{
									$this->import($callback[0]);
									$varValue = $this->{$callback[0]}->{$callback[1]}($flag, $tag, $arrCache[$strTag], $flags, $blnCache, $tags, $arrCache, $_rit, $_cnt); // see #5806

									// Replace the tag and stop the loop
									if ($varValue !== false)
									{
										$arrCache[$strTag] = $varValue;
										break 2;
									}
								}
							}

							$this->log('Unknown insert tag flag "' . $flag . '" in {{' . $strTag . '}}', __METHOD__, TL_ERROR);
							break;
					}
				}
			}

			$strBuffer .= $arrCache[$strTag];
		}

		return StringUtil::restoreBasicEntities($strBuffer);
	}
}

class_alias(InsertTags::class, 'InsertTags');


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Contao\CoreBundle\Exception\InternalServerErrorException;
use Contao\Database\Result;
use Symfony\Component\HttpFoundation\Session\Session;

/**
 * Provide methods to handle newsletters.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class Newsletter extends Backend
{

	/**
	 * Return a form to choose an existing CSV file and import it
	 *
	 * @param DataContainer $dc
	 *
	 * @return string
	 */
	public function send(DataContainer $dc)
	{
		$objNewsletter = $this->Database->prepare("SELECT n.*, c.template AS channelTemplate, c.sender AS channelSender, c.senderName as channelSenderName FROM tl_newsletter n LEFT JOIN tl_newsletter_channel c ON n.pid=c.id WHERE n.id=?")
										->limit(1)
										->execute($dc->id);

		// Return if there is no newsletter
		if ($objNewsletter->numRows < 1)
		{
			return '';
		}

		// Set the template
		if ($objNewsletter->template == '')
		{
			$objNewsletter->template = $objNewsletter->channelTemplate;
		}

		// Set the sender address
		if ($objNewsletter->sender == '')
		{
			$objNewsletter->sender = $objNewsletter->channelSender;
		}

		// Set the sender name
		if ($objNewsletter->senderName == '')
		{
			$objNewsletter->senderName = $objNewsletter->channelSenderName;
		}

		// No sender address given
		if ($objNewsletter->sender == '')
		{
			throw new InternalServerErrorException('No sender address given. Please check the newsletter channel settings.');
		}

		$arrAttachments = array();

		// Add attachments
		if ($objNewsletter->addFile)
		{
			$files = StringUtil::deserialize($objNewsletter->files);

			if (!empty($files) && \is_array($files))
			{
				$objFiles = FilesModel::findMultipleByUuids($files);

				if ($objFiles !== null)
				{
					$rootDir = System::getContainer()->getParameter('kernel.project_dir');

					while ($objFiles->next())
					{
						if (is_file($rootDir . '/' . $objFiles->path))
						{
							$arrAttachments[] = $objFiles->path;
						}
					}
				}
			}
		}

		// Replace insert tags
		$html = $this->replaceInsertTags($objNewsletter->content, false);
		$text = $this->replaceInsertTags($objNewsletter->text, false);

		// Convert relative URLs
		if ($objNewsletter->externalImages)
		{
			$html = $this->convertRelativeUrls($html);
		}

		/** @var Session $objSession */
		$objSession = System::getContainer()->get('session');

		// Send newsletter
		if (Input::get('token') != '' && Input::get('token') == $objSession->get('tl_newsletter_send'))
		{
			$referer = preg_replace('/&(amp;)?(start|mpc|token|recipient|preview)=[^&]*/', '', Environment::get('request'));

			// Preview
			if (isset($_GET['preview']))
			{
				// Check the e-mail address
				if (!Validator::isEmail(Input::get('recipient', true)))
				{
					$_SESSION['TL_PREVIEW_MAIL_ERROR'] = true;
					$this->redirect($referer);
				}

				$arrRecipient['email'] = urldecode(Input::get('recipient', true));

				// Send
				$objEmail = $this->generateEmailObject($objNewsletter, $arrAttachments);
				$this->sendNewsletter($objEmail, $objNewsletter, $arrRecipient, $text, $html);

				// Redirect
				Message::addConfirmation(sprintf($GLOBALS['TL_LANG']['tl_newsletter']['confirm'], 1));
				$this->redirect($referer);
			}

			// Get the total number of recipients
			$objTotal = $this->Database->prepare("SELECT COUNT(DISTINCT email) AS count FROM tl_newsletter_recipients WHERE pid=? AND active=1")
									   ->execute($objNewsletter->pid);

			// Return if there are no recipients
			if ($objTotal->count < 1)
			{
				$objSession->set('tl_newsletter_send', null);
				Message::addError($GLOBALS['TL_LANG']['tl_newsletter']['error']);
				$this->redirect($referer);
			}

			$intTotal = $objTotal->count;

			// Get page and timeout
			$intTimeout = (Input::get('timeout') > 0) ? Input::get('timeout') : 1;
			$intStart = Input::get('start') ?: 0;
			$intPages = Input::get('mpc') ?: 10;

			// Get recipients
			$objRecipients = $this->Database->prepare("SELECT *, r.email FROM tl_newsletter_recipients r LEFT JOIN tl_member m ON(r.email=m.email) WHERE r.pid=? AND r.active=1 ORDER BY r.email")
											->limit($intPages, $intStart)
											->execute($objNewsletter->pid);

			echo '
';

			// Send newsletter
			if ($objRecipients->numRows > 0)
			{
				// Update status
				if ($intStart == 0)
				{
					$this->Database->prepare("UPDATE tl_newsletter SET sent='1', date=? WHERE id=?")
								   ->execute(time(), $objNewsletter->id);

					$_SESSION['REJECTED_RECIPIENTS'] = array();
				}

				while ($objRecipients->next())
				{
					// Skip the recipient if the member is not active (see #8812)
					if ($objRecipients->id !== null && ($objRecipients->disable || ($objRecipients->start != '' && $objRecipients->start > time()) || ($objRecipients->stop != '' && $objRecipients->stop < time())))
					{
						--$intTotal;
						echo 'Skipping ' . Idna::decodeEmail($objRecipients->email) . ' as the member is not active
';
						continue;
					}

					$objEmail = $this->generateEmailObject($objNewsletter, $arrAttachments);
					$this->sendNewsletter($objEmail, $objNewsletter, $objRecipients->row(), $text, $html);

					echo 'Sending newsletter to ' . Idna::decodeEmail($objRecipients->email) . '
';
				}
			}

			echo '
';

			// Redirect back home
			if ($objRecipients->numRows < 1 || ($intStart + $intPages) >= $intTotal)
			{
				$objSession->set('tl_newsletter_send', null);

				// Deactivate rejected addresses
				if (!empty($_SESSION['REJECTED_RECIPIENTS']))
				{
					$intRejected = \count($_SESSION['REJECTED_RECIPIENTS']);
					Message::addInfo(sprintf($GLOBALS['TL_LANG']['tl_newsletter']['rejected'], $intRejected));
					$intTotal -= $intRejected;

					foreach ($_SESSION['REJECTED_RECIPIENTS'] as $strRecipient)
					{
						$this->Database->prepare("UPDATE tl_newsletter_recipients SET active='' WHERE email=?")
									   ->execute($strRecipient);

						$this->log('Recipient address "' . Idna::decodeEmail($strRecipient) . '" was rejected and has been deactivated', __METHOD__, TL_ERROR);
					}
				}

				Message::addConfirmation(sprintf($GLOBALS['TL_LANG']['tl_newsletter']['confirm'], $intTotal));

				echo '';
				echo 'Please click here to proceed if you are not using JavaScript';
			}

			// Redirect to the next cycle
			else
			{
				$url = preg_replace('/&(amp;)?(start|mpc|recipient)=[^&]*/', '', Environment::get('request')) . '&start=' . ($intStart + $intPages) . '&mpc=' . $intPages;

				echo '';
				echo 'Please click here to proceed if you are not using JavaScript';
			}

			echo '

';
			exit;
		}

		$strToken = md5(uniqid(mt_rand(), true));
		$objSession->set('tl_newsletter_send', $strToken);
		$sprintf = ($objNewsletter->senderName != '') ? $objNewsletter->senderName . ' <%s>' : '%s';
		$this->import(BackendUser::class, 'User');

		// Preview newsletter
		$return = Message::generate() . '

getReferer(true).'" class="header_back" title="'.StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['backBTTitle']).'" accesskey="b">'.$GLOBALS['TL_LANG']['MSC']['backBT'].'

<form action="'.TL_SCRIPT.'" id="tl_newsletter_send" class="tl_form tl_edit_form" method="get">

<input type="hidden" name="do" value="' . Input::get('do') . '">
<input type="hidden" name="table" value="' . Input::get('table') . '">
<input type="hidden" name="key" value="' . Input::get('key') . '">
<input type="hidden" name="id" value="' . Input::get('id') . '">
<input type="hidden" name="token" value="' . $strToken . '">

    ' . $GLOBALS['TL_LANG']['tl_newsletter']['from'] . '
    ' . sprintf($sprintf, Idna::decodeEmail($objNewsletter->sender)) . '

    ' . $GLOBALS['TL_LANG']['tl_newsletter']['subject'][0] . '
    ' . $objNewsletter->subject . '

    ' . $GLOBALS['TL_LANG']['tl_newsletter']['template'][0] . '
    ' . $objNewsletter->template . '

' . ((!empty($arrAttachments) && \is_array($arrAttachments)) ? '

    ' . $GLOBALS['TL_LANG']['tl_newsletter']['attachments'] . '
    ' . implode(', ', $arrAttachments) . '

' : '') . '

' . (!$objNewsletter->sendText ? '

' . $html . '

' : '') . '

' . $text . '

<fieldset class="tl_tbox nolegend">

' . $GLOBALS['TL_LANG']['tl_newsletter']['mailsPerCycle'][0] . '

  <input type="text" name="mpc" id="ctrl_mpc" value="10" class="tl_text" onfocus="Backend.getScrollOffset()">' . (($GLOBALS['TL_LANG']['tl_newsletter']['mailsPerCycle'][1] && Config::get('showHelp')) ? '

' . $GLOBALS['TL_LANG']['tl_newsletter']['mailsPerCycle'][1] . '
' : '') . '

' . $GLOBALS['TL_LANG']['tl_newsletter']['timeout'][0] . '

  <input type="text" name="timeout" id="ctrl_timeout" value="1" class="tl_text" onfocus="Backend.getScrollOffset()">' . (($GLOBALS['TL_LANG']['tl_newsletter']['timeout'][1] && Config::get('showHelp')) ? '

' . $GLOBALS['TL_LANG']['tl_newsletter']['timeout'][1] . '
' : '') . '

' . $GLOBALS['TL_LANG']['tl_newsletter']['start'][0] . '

  <input type="text" name="start" id="ctrl_start" value="0" class="tl_text" onfocus="Backend.getScrollOffset()">' . (($GLOBALS['TL_LANG']['tl_newsletter']['start'][1] && Config::get('showHelp')) ? '

' . sprintf($GLOBALS['TL_LANG']['tl_newsletter']['start'][1], $objNewsletter->id) . '
' : '') . '

' . $GLOBALS['TL_LANG']['tl_newsletter']['sendPreviewTo'][0] . '

  <input type="text" name="recipient" id="ctrl_recipient" value="'.Idna::decodeEmail($this->User->email).'" class="tl_text" onfocus="Backend.getScrollOffset()">' . (isset($_SESSION['TL_PREVIEW_MAIL_ERROR']) ? '

' . $GLOBALS['TL_LANG']['ERR']['email'] . '
' : (($GLOBALS['TL_LANG']['tl_newsletter']['sendPreviewTo'][1] && Config::get('showHelp')) ? '

' . $GLOBALS['TL_LANG']['tl_newsletter']['sendPreviewTo'][1] . '
' : '')) . '

</fieldset>

';

		$return .= '

<button type="submit" name="preview" class="tl_submit" accesskey="p">'.$GLOBALS['TL_LANG']['tl_newsletter']['preview'].'</button>
<button type="submit" id="send" class="tl_submit" accesskey="s" onclick="return confirm(\''. str_replace("'", "\\'", $GLOBALS['TL_LANG']['tl_newsletter']['sendConfirm']) .'\')">'.$GLOBALS['TL_LANG']['tl_newsletter']['send'][0].'</button>

</form>';

		unset($_SESSION['TL_PREVIEW_MAIL_ERROR']);

		return $return;
	}

	/**
	 * Generate the e-mail object and return it
	 *
	 * @param Result $objNewsletter
	 * @param array  $arrAttachments
	 *
	 * @return Email
	 */
	protected function generateEmailObject(Result $objNewsletter, $arrAttachments)
	{
		$objEmail = new Email();
		$objEmail->from = $objNewsletter->sender;
		$objEmail->subject = $objNewsletter->subject;

		// Add sender name
		if ($objNewsletter->senderName != '')
		{
			$objEmail->fromName = $objNewsletter->senderName;
		}

		$objEmail->embedImages = !$objNewsletter->externalImages;
		$objEmail->logFile = TL_NEWSLETTER . '_' . $objNewsletter->id;

		// Attachments
		if (!empty($arrAttachments) && \is_array($arrAttachments))
		{
			$rootDir = System::getContainer()->getParameter('kernel.project_dir');

			foreach ($arrAttachments as $strAttachment)
			{
				$objEmail->attachFile($rootDir . '/' . $strAttachment);
			}
		}

		return $objEmail;
	}

	/**
	 * Compile the newsletter and send it
	 *
	 * @param Email  $objEmail
	 * @param Result $objNewsletter
	 * @param array  $arrRecipient
	 * @param string $text
	 * @param string $html
	 * @param string $css
	 */
	protected function sendNewsletter(Email $objEmail, Result $objNewsletter, $arrRecipient, $text, $html, $css=null)
	{
		// Prepare the text content
		$objEmail->text = StringUtil::parseSimpleTokens($text, $arrRecipient);

		if (!$objNewsletter->sendText)
		{
			// Default template
			if ($objNewsletter->template == '')
			{
				$objNewsletter->template = 'mail_default';
			}

			$objTemplate = new BackendTemplate($objNewsletter->template);
			$objTemplate->setData($objNewsletter->row());
			$objTemplate->title = $objNewsletter->subject;
			$objTemplate->body = StringUtil::parseSimpleTokens($html, $arrRecipient);
			$objTemplate->charset = Config::get('characterSet');
			$objTemplate->recipient = $arrRecipient['email'];

			// Deprecated since Contao 4.0, to be removed in Contao 5.0
			$objTemplate->css = $css;

			// Parse template
			$objEmail->html = $objTemplate->parse();
			$objEmail->imageDir = System::getContainer()->getParameter('kernel.project_dir') . '/';
		}

		// Deactivate invalid addresses
		try
		{
			$objEmail->sendTo($arrRecipient['email']);
		}
		catch (\Swift_RfcComplianceException $e)
		{
			$_SESSION['REJECTED_RECIPIENTS'][] = $arrRecipient['email'];
		}

		// Rejected recipients
		if ($objEmail->hasFailures())
		{
			$_SESSION['REJECTED_RECIPIENTS'][] = $arrRecipient['email'];
		}

		// HOOK: add custom logic
		if (isset($GLOBALS['TL_HOOKS']['sendNewsletter']) && \is_array($GLOBALS['TL_HOOKS']['sendNewsletter']))
		{
			foreach ($GLOBALS['TL_HOOKS']['sendNewsletter'] as $callback)
			{
				$this->import($callback[0]);
				$this->{$callback[0]}->{$callback[1]}($objEmail, $objNewsletter, $arrRecipient, $text, $html);
			}
		}
	}

	/**
	 * Return a form to choose a CSV file and import it
	 *
	 * @return string
	 */
	public function importRecipients()
	{
		if (Input::get('key') != 'import')
		{
			return '';
		}

		/** @var FileUpload $objUploader */
		$objUploader = new FileUpload();

		// Import recipients
		if (Input::post('FORM_SUBMIT') == 'tl_recipients_import')
		{
			$arrUploaded = $objUploader->uploadTo('system/tmp');

			if (empty($arrUploaded))
			{
				Message::addError($GLOBALS['TL_LANG']['ERR']['all_fields']);
				$this->reload();
			}

			$time = time();
			$intTotal = 0;
			$intInvalid = 0;

			foreach ($arrUploaded as $strCsvFile)
			{
				$objFile = new File($strCsvFile);

				if ($objFile->extension != 'csv')
				{
					Message::addError(sprintf($GLOBALS['TL_LANG']['ERR']['filetype'], $objFile->extension));
					continue;
				}

				// Get separator
				switch (Input::post('separator'))
				{
					case 'semicolon':
						$strSeparator = ';';
						break;

					case 'tabulator':
						$strSeparator = "\t";
						break;

					case 'linebreak':
						$strSeparator = "\n";
						break;

					default:
						$strSeparator = ',';
						break;
				}

				$arrRecipients = array();
				$resFile = $objFile->handle;

				while(($arrRow = @fgetcsv($resFile, null, $strSeparator)) !== false)
				{
					$arrRecipients[] = $arrRow;
				}

				if (!empty($arrRecipients))
				{
					$arrRecipients = array_merge(...$arrRecipients);
				}

				$arrRecipients = array_filter(array_unique($arrRecipients));

				foreach ($arrRecipients as $strRecipient)
				{
					// Skip invalid entries
					if (!Validator::isEmail($strRecipient))
					{
						$this->log('The recipient address "' . $strRecipient . '" seems to be invalid and was not imported', __METHOD__, TL_ERROR);
						++$intInvalid;
						continue;
					}

					// Check whether the e-mail address exists
					$objRecipient = $this->Database->prepare("SELECT COUNT(*) AS count FROM tl_newsletter_recipients WHERE pid=? AND email=?")
												   ->execute(Input::get('id'), $strRecipient);

					if ($objRecipient->count > 0)
					{
						continue;
					}

					// Check whether the e-mail address has been blacklisted
					$objBlacklist = $this->Database->prepare("SELECT COUNT(*) AS count FROM tl_newsletter_blacklist WHERE pid=? AND hash=?")
												   ->execute(Input::get('id'), md5($strRecipient));

					if ($objBlacklist->count > 0)
					{
						$this->log('Recipient "' . $strRecipient . '" has unsubscribed from channel ID "' . Input::get('id') . '" and was not imported', __METHOD__, TL_ERROR);
						continue;
					}

					$this->Database->prepare("INSERT INTO tl_newsletter_recipients SET pid=?, tstamp=$time, email=?, active=1")
								   ->execute(Input::get('id'), $strRecipient);

					++$intTotal;
				}
			}

			Message::addConfirmation(sprintf($GLOBALS['TL_LANG']['tl_newsletter_recipients']['confirm'], $intTotal));

			if ($intInvalid > 0)
			{
				Message::addInfo(sprintf($GLOBALS['TL_LANG']['tl_newsletter_recipients']['invalid'], $intInvalid));
			}

			System::setCookie('BE_PAGE_OFFSET', 0, 0);
			$this->reload();
		}

		// Return form
		return '

'.$GLOBALS['TL_LANG']['MSC']['backBT'].'

'.Message::generate().'
<form action="'.ampersand(Environment::get('request'), true).'" id="tl_recipients_import" class="tl_form tl_edit_form" method="post" enctype="multipart/form-data">

<input type="hidden" name="FORM_SUBMIT" value="tl_recipients_import">
<input type="hidden" name="REQUEST_TOKEN" value="'.REQUEST_TOKEN.'">
<input type="hidden" name="MAX_FILE_SIZE" value="'.Config::get('maxFileSize').'">

<fieldset class="tl_tbox nolegend">

'.$GLOBALS['TL_LANG']['MSC']['separator'][0].'

    <select name="separator" id="separator" class="tl_select" onfocus="Backend.getScrollOffset()">
      <option value="comma">'.$GLOBALS['TL_LANG']['MSC']['comma'].'</option>
      <option value="semicolon">'.$GLOBALS['TL_LANG']['MSC']['semicolon'].'</option>
      <option value="tabulator">'.$GLOBALS['TL_LANG']['MSC']['tabulator'].'</option>
      <option value="linebreak">'.$GLOBALS['TL_LANG']['MSC']['linebreak'].'</option>
    </select>'.(($GLOBALS['TL_LANG']['MSC']['separator'][1] != '') ? '

'.$GLOBALS['TL_LANG']['MSC']['separator'][1].'
' : '').'

'.$GLOBALS['TL_LANG']['MSC']['source'][0].'
'.$objUploader->generateMarkup().(isset($GLOBALS['TL_LANG']['MSC']['source'][1]) ? '

'.$GLOBALS['TL_LANG']['MSC']['source'][1].'
' : '').'

</fieldset>

  <button type="submit" name="save" id="save" class="tl_submit" accesskey="s">'.$GLOBALS['TL_LANG']['tl_newsletter_recipients']['import'][0].'</button>

</form>';
	}

	/**
	 * Remove the newsletter subscriptions of members who close their account
	 *
	 * @param integer $intUser
	 * @param string  $strMode
	 */
	public function removeSubscriptions($intUser, $strMode)
	{
		if (!$intUser)
		{
			return;
		}

		// Delete or deactivate
		if ($strMode == 'close_delete')
		{
			$this->Database->prepare("DELETE FROM tl_newsletter_recipients WHERE email=(SELECT email FROM tl_member WHERE id=?)")
						   ->execute($intUser);
		}
		else
		{
			$this->Database->prepare("UPDATE tl_newsletter_recipients SET active='' WHERE email=(SELECT email FROM tl_member WHERE id=?)")
						   ->execute($intUser);
		}
	}

	/**
	 * Synchronize newsletter subscription of new users
	 *
	 * @param MemberModel $intUser
	 * @param array       $arrData
	 */
	public function createNewUser($intUser, $arrData)
	{
		$arrNewsletters = StringUtil::deserialize($arrData['newsletter'], true);

		// Return if there are no newsletters
		if (!\is_array($arrNewsletters))
		{
			return;
		}

		$time = time();

		// Add recipients
		foreach ($arrNewsletters as $intNewsletter)
		{
			$intNewsletter = (int) $intNewsletter;

			if ($intNewsletter < 1)
			{
				continue;
			}

			$objRecipient = $this->Database->prepare("SELECT COUNT(*) AS count FROM tl_newsletter_recipients WHERE pid=? AND email=?")
										   ->execute($intNewsletter, $arrData['email']);

			if ($objRecipient->count < 1)
			{
				$this->Database->prepare("INSERT INTO tl_newsletter_recipients SET pid=?, tstamp=$time, email=?, addedOn=$time")
							   ->execute($intNewsletter, $arrData['email']);
			}
		}
	}

	/**
	 * Activate newsletter subscription of new users
	 *
	 * @param MemberModel $objUser
	 */
	public function activateAccount($objUser)
	{
		$arrNewsletters = StringUtil::deserialize($objUser->newsletter, true);

		// Return if there are no newsletters
		if (!\is_array($arrNewsletters))
		{
			return;
		}

		// Activate e-mail addresses
		foreach ($arrNewsletters as $intNewsletter)
		{
			$intNewsletter = (int) $intNewsletter;

			if ($intNewsletter < 1)
			{
				continue;
			}

			$this->Database->prepare("UPDATE tl_newsletter_recipients SET active='1' WHERE pid=? AND email=?")
						   ->execute($intNewsletter, $objUser->email);
		}
	}

	/**
	 * Synchronize the newsletter subscriptions if the visibility is toggled
	 *
	 * @param boolean       $blnDisabled
	 * @param DataContainer $dc
	 *
	 * @return boolean
	 */
	public function onToggleVisibility($blnDisabled, DataContainer $dc)
	{
		if (!$dc->id)
		{
			return $blnDisabled;
		}

		$objUser = $this->Database->prepare("SELECT email FROM tl_member WHERE id=?")
								  ->limit(1)
								  ->execute($dc->id);

		if ($objUser->numRows)
		{
			$this->Database->prepare("UPDATE tl_newsletter_recipients SET tstamp=?, active=? WHERE email=?")
						   ->execute(time(), ($blnDisabled ? '' : '1'), $objUser->email);
		}

		return $blnDisabled;
	}

	/**
	 * Synchronize newsletter subscription of existing users
	 *
	 * @param mixed       $varValue
	 * @param MemberModel $objUser
	 * @param ModuleModel $objModule
	 *
	 * @return mixed
	 */
	public function synchronize($varValue, $objUser, $objModule=null)
	{
		// Return if there is no user (e.g. upon registration)
		if ($objUser === null)
		{
			return $varValue;
		}

		$blnIsFrontend = true;

		// If called from the back end, the second argument is a DataContainer object
		if ($objUser instanceof DataContainer)
		{
			$objUser = $this->Database->prepare("SELECT * FROM tl_member WHERE id=?")
									  ->limit(1)
									  ->execute($objUser->id);

			if ($objUser->numRows < 1)
			{
				return $varValue;
			}

			$blnIsFrontend = false;
		}

		// Nothing has changed or e-mail address is empty
		if ($varValue == $objUser->newsletter || $objUser->email == '')
		{
			return $varValue;
		}

		$time = time();
		$varValue = StringUtil::deserialize($varValue, true);

		// Get all channel IDs (thanks to Andreas Schempp)
		if ($blnIsFrontend && $objModule instanceof Module)
		{
			$arrChannel = StringUtil::deserialize($objModule->newsletters, true);
		}
		else
		{
			$arrChannel = $this->Database->query("SELECT id FROM tl_newsletter_channel")->fetchEach('id');
		}

		$arrDelete = array_values(array_diff($arrChannel, $varValue));

		// Delete existing recipients
		if (!empty($arrDelete) && \is_array($arrDelete))
		{
			$this->Database->prepare("DELETE FROM tl_newsletter_recipients WHERE pid IN(" . implode(',', array_map('\intval', $arrDelete)) . ") AND email=?")
						   ->execute($objUser->email);
		}

		// Add recipients
		foreach ($varValue as $intId)
		{
			$intId = (int) $intId;

			if ($intId < 1)
			{
				continue;
			}

			$objRecipient = $this->Database->prepare("SELECT COUNT(*) AS count FROM tl_newsletter_recipients WHERE pid=? AND email=?")
										   ->execute($intId, $objUser->email);

			if ($objRecipient->count < 1)
			{
				$this->Database->prepare("INSERT INTO tl_newsletter_recipients SET pid=?, tstamp=$time, email=?, active=?, addedOn=?")
							   ->execute($intId, $objUser->email, ($objUser->disable ? '' : 1), ($blnIsFrontend ? $time : ''));
			}
		}

		return serialize($varValue);
	}

	/**
	 * Update a particular member account
	 */
	public function updateAccount()
	{
		$intUser = Input::get('id');

		// Front end call
		if (TL_MODE == 'FE')
		{
			$this->import(FrontendUser::class, 'User');
			$intUser = $this->User->id;
		}

		// Return if there is no user (e.g. upon registration)
		if (!$intUser)
		{
			return;
		}

		// Edit account
		if (TL_MODE == 'FE' || Input::get('act') == 'edit')
		{
			$objUser = $this->Database->prepare("SELECT email, disable FROM tl_member WHERE id=?")
									  ->limit(1)
									  ->execute($intUser);

			if ($objUser->numRows)
			{
				$strEmail = Input::post('email', true);

				// E-mail address has changed
				if (!empty($_POST) && $strEmail != '' && $strEmail != $objUser->email)
				{
					$objCount = $this->Database->prepare("SELECT COUNT(*) AS count FROM tl_newsletter_recipients WHERE email=?")
											   ->execute($strEmail);

					// Delete the old subscription if the new e-mail address exists (see #19)
					if ($objCount->count > 0)
					{
						$this->Database->prepare("DELETE FROM tl_newsletter_recipients WHERE email=?")
									   ->execute($objUser->email);
					}
					else
					{
						$this->Database->prepare("UPDATE tl_newsletter_recipients SET email=? WHERE email=?")
									   ->execute($strEmail, $objUser->email);
					}

					$objUser->email = $strEmail;
				}

				$objSubscriptions = $this->Database->prepare("SELECT pid FROM tl_newsletter_recipients WHERE email=?")
												   ->execute($objUser->email);

				if ($objSubscriptions->numRows)
				{
					$strNewsletters = serialize($objSubscriptions->fetchEach('pid'));
				}
				else
				{
					$strNewsletters = '';
				}

				$this->Database->prepare("UPDATE tl_member SET newsletter=? WHERE id=?")
							   ->execute($strNewsletters, $intUser);

				// Update the front end user object
				if (TL_MODE == 'FE')
				{
					$this->User->newsletter = $strNewsletters;
				}

				// Check activation status
				elseif (!empty($_POST) && Input::post('disable') != $objUser->disable)
				{
					$this->Database->prepare("UPDATE tl_newsletter_recipients SET active=? WHERE email=?")
								   ->execute((Input::post('disable') ? '' : 1), $objUser->email);

					$objUser->disable = Input::post('disable');
				}
			}
		}

		// Delete account
		elseif (Input::get('act') == 'delete')
		{
			$objUser = $this->Database->prepare("SELECT email FROM tl_member WHERE id=?")
									  ->limit(1)
									  ->execute($intUser);

			if ($objUser->numRows)
			{
				$this->Database->prepare("DELETE FROM tl_newsletter_recipients WHERE email=?")
							   ->execute($objUser->email);
			}
		}
	}

	/**
	 * Purge subscriptions that have not been activated within 24 hours
	 */
	public function purgeSubscriptions()
	{
		$objRecipient = NewsletterRecipientsModel::findExpiredSubscriptions();

		if ($objRecipient === null)
		{
			return;
		}

		foreach ($objRecipient as $objModel)
		{
			$objModel->delete();
		}

		// Add a log entry
		$this->log('Purged the unactivated newsletter subscriptions', __METHOD__, TL_CRON);
	}

	/**
	 * Get all editable newsletters and return them as array
	 *
	 * @param ModuleModel $objModule
	 *
	 * @return array
	 */
	public function getNewsletters($objModule)
	{
		$objNewsletter = NewsletterChannelModel::findAll();

		if ($objNewsletter === null)
		{
			return array();
		}

		$arrNewsletters = array();

		// Return all channels if $objModule is null (see #5874)
		if ($objModule === null || TL_MODE == 'BE')
		{
			while ($objNewsletter->next())
			{
				$arrNewsletters[$objNewsletter->id] = $objNewsletter->title;
			}
		}
		else
		{
			$newsletters = StringUtil::deserialize($objModule->newsletters, true);

			if (empty($newsletters) || !\is_array($newsletters))
			{
				return array();
			}

			while ($objNewsletter->next())
			{
				if (\in_array($objNewsletter->id, $newsletters))
				{
					$arrNewsletters[$objNewsletter->id] = $objNewsletter->title;
				}
			}
		}

		natsort($arrNewsletters); // see #7864

		return $arrNewsletters;
	}

	/**
	 * Add newsletters to the indexer
	 *
	 * @param array   $arrPages
	 * @param integer $intRoot
	 * @param boolean $blnIsSitemap
	 *
	 * @return array
	 */
	public function getSearchablePages($arrPages, $intRoot=0, $blnIsSitemap=false)
	{
		$arrRoot = array();

		if ($intRoot > 0)
		{
			$arrRoot = $this->Database->getChildRecords($intRoot, 'tl_page');
		}

		$arrProcessed = array();
		$time = Date::floorToMinute();

		// Get all channels
		$objNewsletter = NewsletterChannelModel::findAll();

		// Walk through each channel
		if ($objNewsletter !== null)
		{
			while ($objNewsletter->next())
			{
				if (!$objNewsletter->jumpTo)
				{
					continue;
				}

				// Skip channels outside the root nodes
				if (!empty($arrRoot) && !\in_array($objNewsletter->jumpTo, $arrRoot))
				{
					continue;
				}

				// Get the URL of the jumpTo page
				if (!isset($arrProcessed[$objNewsletter->jumpTo]))
				{
					$objParent = PageModel::findWithDetails($objNewsletter->jumpTo);

					// The target page does not exist
					if ($objParent === null)
					{
						continue;
					}

					// The target page has not been published (see #5520)
					if (!$objParent->published || ($objParent->start != '' && $objParent->start > $time) || ($objParent->stop != '' && $objParent->stop <= ($time + 60)))
					{
						continue;
					}

					if ($blnIsSitemap)
					{
						// The target page is protected (see #8416)
						if ($objParent->protected)
						{
							continue;
						}

						// The target page is exempt from the sitemap (see #6418)
						if ($objParent->sitemap == 'map_never')
						{
							continue;
						}
					}

					// Generate the URL
					$arrProcessed[$objNewsletter->jumpTo] = $objParent->getAbsoluteUrl(Config::get('useAutoItem') ? '/%s' : '/items/%s');
				}

				$strUrl = $arrProcessed[$objNewsletter->jumpTo];

				// Get the items
				$objItem = NewsletterModel::findSentByPid($objNewsletter->id);

				if ($objItem !== null)
				{
					while ($objItem->next())
					{
						$arrPages[] = sprintf(preg_replace('/%(?!s)/', '%%', $strUrl), ($objItem->alias ?: $objItem->id));
					}
				}
			}
		}

		return $arrPages;
	}
}

class_alias(Newsletter::class, 'Newsletter');


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

/**
 * A TextStore field is used to enter data only. It will not show the
 * currently stored value (useful e.g. to store passwords).
 *
 * @property integer $maxlength
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class TextStore extends Widget
{

	/**
	 * Submit user input
	 * @var boolean
	 */
	protected $blnSubmitInput = true;

	/**
	 * Template
	 * @var string
	 */
	protected $strTemplate = 'be_widget';

	/**
	 * Add specific attributes
	 *
	 * @param string $strKey
	 * @param mixed  $varValue
	 */
	public function __set($strKey, $varValue)
	{
		switch ($strKey)
		{
			case 'maxlength':
				if ($varValue > 0)
				{
					$this->arrAttributes['maxlength'] = $varValue;
				}
				break;

			default:
				parent::__set($strKey, $varValue);
				break;
		}
	}

	/**
	 * Ignore the field if nothing has been entered
	 *
	 * @param mixed $varInput
	 *
	 * @return mixed
	 */
	protected function validator($varInput)
	{
		if ($varInput == '*****')
		{
			$this->blnSubmitInput = false;

			return true;
		}

		return parent::validator($varInput);
	}

	/**
	 * Generate the widget and return it as string
	 *
	 * @return string
	 */
	public function generate()
	{
		return sprintf('<input type="password" name="%s" id="ctrl_%s" class="tl_text%s" value="%s"%s onfocus="Backend.getScrollOffset()">%s',
						$this->strName,
						$this->strId,
						(($this->strClass != '') ? ' ' . $this->strClass : ''),
						(($this->varValue != '') ? '*****' : ''),
						$this->getAttributes(),
						$this->wizard);
	}
}

class_alias(TextStore::class, 'TextStore');


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

/**
 * Content element "YouTube".
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class ContentYouTube extends ContentElement
{

	/**
	 * Template
	 * @var string
	 */
	protected $strTemplate = 'ce_youtube';

	/**
	 * Show the YouTube link in the back end
	 *
	 * @return string
	 */
	public function generate()
	{
		if ($this->youtube == '')
		{
			return '';
		}

		if (TL_MODE == 'BE')
		{
			$return = '
youtube . '" target="_blank" rel="noreferrer noopener">youtu.be/' . $this->youtube . '
';

			if ($this->headline != '')
			{
				$return = '<'. $this->hl .'>'. $this->headline .'</'. $this->hl .'>'. $return;
			}

			return $return;
		}

		return parent::generate();
	}

	/**
	 * Generate the module
	 */
	protected function compile()
	{
		$size = StringUtil::deserialize($this->playerSize);

		if (!\is_array($size) || empty($size[0]) || empty($size[1]))
		{
			$this->Template->size = ' width="640" height="360"';
		}
		else
		{
			$this->Template->size = ' width="' . $size[0] . '" height="' . $size[1] . '"';
		}

		$params = array();
		$options = StringUtil::deserialize($this->youtubeOptions);
		$domain = 'https://www.youtube.com';

		if (\is_array($options))
		{
			foreach ($options as $option)
			{
				switch ($option)
				{
					case 'youtube_fs':
					case 'youtube_rel':
					case 'youtube_showinfo':
					case 'youtube_controls':
						$params[] = substr($option, 8) . '=0';
						break;

					case 'youtube_hl':
						$params[] = substr($option, 8) . '=' . substr($GLOBALS['TL_LANGUAGE'], 0, 2);
						break;

					case 'youtube_iv_load_policy':
						$params[] = substr($option, 8) . '=3';
						break;

					case 'youtube_nocookie':
						$domain = 'https://www.youtube-nocookie.com';
						break;

					default:
						$params[] = substr($option, 8) . '=1';
				}
			}
		}

		if ($this->playerStart > 0)
		{
			$params[] = 'start=' . (int) $this->playerStart;
		}

		if ($this->playerStop > 0)
		{
			$params[] = 'end=' . (int) $this->playerStop;
		}

		$url = $domain . '/embed/' . $this->youtube;

		if (!empty($params))
		{
			$url .= '?' . implode('&', $params);
		}

		$this->Template->src = $url;
		$this->Template->aspect = str_replace(':', '', $this->playerAspect);
		$this->Template->caption = $this->playerCaption;
	}
}

class_alias(ContentYouTube::class, 'ContentYouTube');