feat: add registration

This commit is contained in:
Maël Gangloff
2024-08-05 01:30:27 +02:00
parent 1bb63cdc3b
commit 925f3708c0
20 changed files with 371 additions and 44 deletions

2
.env
View File

@@ -57,7 +57,9 @@ LOCK_DSN=flock
###< symfony/lock ### ###< symfony/lock ###
MAILER_SENDER_NAME="Domain Watchdog"
MAILER_SENDER_EMAIL=notifications@example.com MAILER_SENDER_EMAIL=notifications@example.com
REGISTRATION_ENABLED=true
LIMITED_FEATURES=false LIMITED_FEATURES=false
OAUTH_CLIENT_ID= OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET= OAUTH_CLIENT_SECRET=

View File

@@ -103,7 +103,9 @@ export default function App() {
<Typography.Link href='https://github.com/maelgangloff/domain-watchdog/wiki'><Button <Typography.Link href='https://github.com/maelgangloff/domain-watchdog/wiki'><Button
type='text'>{t`Documentation`}</Button></Typography.Link> type='text'>{t`Documentation`}</Button></Typography.Link>
</Space> </Space>
<Typography.Paragraph>{jt`${ProjectLink} is an open source project distributed under the ${LicenseLink} license.`}</Typography.Paragraph> <Typography.Paragraph style={{marginTop: '1em'}}>
{jt`${ProjectLink} is an open source project distributed under the ${LicenseLink} license.`}
</Typography.Paragraph>
</Layout.Footer> </Layout.Footer>
</Layout> </Layout>
</Layout> </Layout>

View File

@@ -71,7 +71,7 @@
"symfony/web-link": "7.1.*", "symfony/web-link": "7.1.*",
"symfony/webpack-encore-bundle": "^2.1", "symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.1.*", "symfony/yaml": "7.1.*",
"symfonycasts/verify-email-bundle": "^1.17", "symfonycasts/verify-email-bundle": "*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0" "twig/twig": "^2.12|^3.0"
}, },

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "6b52bd3ad92da490e7a206500d0c0348", "content-hash": "69d672f9e5a01b48f871fa5c81714f8d",
"packages": [ "packages": [
{ {
"name": "api-platform/core", "name": "api-platform/core",

View File

@@ -18,4 +18,5 @@ return [
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
]; ];

View File

@@ -1,6 +1,5 @@
framework: framework:
rate_limiter: rate_limiter:
# define 2 rate limiters (one for username+IP, the other for IP)
username_ip_login: username_ip_login:
policy: token_bucket policy: token_bucket
limit: 5 limit: 5
@@ -11,8 +10,17 @@ framework:
limit: 50 limit: 50
interval: '15 minutes' interval: '15 minutes'
user_register:
policy: token_bucket
limit: 1
rate: { interval: '5 minutes' }
rdap_requests:
policy: sliding_window
limit: 10
interval: '1 hour'
services: services:
# our custom login rate limiter
app.login_rate_limiter: app.login_rate_limiter:
class: Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter class: Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter
arguments: arguments:
@@ -69,6 +77,7 @@ security:
access_control: access_control:
- { path: ^/api$, roles: PUBLIC_ACCESS } - { path: ^/api$, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/register$, roles: PUBLIC_ACCESS }
- { path: "^/api/watchlists/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/calendar$", roles: PUBLIC_ACCESS } - { path: "^/api/watchlists/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/calendar$", roles: PUBLIC_ACCESS }
- { path: "^/api/config$", roles: PUBLIC_ACCESS } - { path: "^/api/config$", roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

View File

@@ -5,8 +5,10 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
mailer_sender_email: '%env(string:MAILER_SENDER_EMAIL)%' mailer_sender_email: '%env(string:MAILER_SENDER_EMAIL)%'
mailer_sender_name: '%env(string:MAILER_SENDER_NAME)'
oauth_enabled: '%env(OAUTH_CLIENT_ID)%' oauth_enabled: '%env(OAUTH_CLIENT_ID)%'
limited_features: '%env(bool:LIMITED_FEATURES)%' limited_features: '%env(bool:LIMITED_FEATURES)%'
registration_enabled: '%env(bool:REGISTRATION_ENABLED)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@@ -15,6 +17,7 @@ services:
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind: bind:
$mailerSenderEmail: '%mailer_sender_email%' $mailerSenderEmail: '%mailer_sender_email%'
$mailerSenderName: '%mailer_sender_name%'
# makes classes in src/ available to be used as services # makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name # this creates a service per class whose id is the fully-qualified class name

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240804214457 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD is_verified BOOLEAN NOT NULL DEFAULT true;');
$this->addSql('ALTER TABLE "user" ALTER is_verified DROP DEFAULT');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" DROP is_verified');
}
}

View File

@@ -23,7 +23,7 @@ class DomainRefreshController extends AbstractController
{ {
public function __construct(private readonly DomainRepository $domainRepository, public function __construct(private readonly DomainRepository $domainRepository,
private readonly RDAPService $RDAPService, private readonly RDAPService $RDAPService,
private readonly RateLimiterFactory $authenticatedApiLimiter, private readonly RateLimiterFactory $rdapRequestsLimiter,
private readonly MessageBusInterface $bus, private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
@@ -63,7 +63,8 @@ class DomainRefreshController extends AbstractController
} }
if (false === $kernel->isDebug() && true === $this->getParameter('limited_features')) { if (false === $kernel->isDebug() && true === $this->getParameter('limited_features')) {
$limiter = $this->authenticatedApiLimiter->create($userId); $limiter = $this->rdapRequestsLimiter->create($userId);
if (false === $limiter->consume()->isAccepted()) { if (false === $limiter->consume()->isAccepted()) {
$this->logger->warning('User {username} was rate limited by the API.', [ $this->logger->warning('User {username} was rate limited by the API.', [
'username' => $this->getUser()->getUserIdentifier(), 'username' => $this->getUser()->getUserIdentifier(),

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
class RegistrationController extends AbstractController
{
public function __construct(
private readonly EmailVerifier $emailVerifier,
private readonly string $mailerSenderEmail,
private readonly string $mailerSenderName,
private readonly RateLimiterFactory $userRegisterLimiter,
private readonly EntityManagerInterface $em,
private readonly SerializerInterface $serializer,
private readonly LoggerInterface $logger,
) {
}
/**
* @throws TransportExceptionInterface
*/
#[Route(
path: '/api/register',
name: 'user_register',
defaults: [
'_api_resource_class' => User::class,
'_api_operation_name' => 'register',
],
methods: ['POST']
)]
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher): Response
{
if (false === $this->getParameter('registration_enabled')) {
throw new UnauthorizedHttpException('', 'Registration is disabled on this instance');
}
$limiter = $this->userRegisterLimiter->create($request->getClientIp());
if (false === $limiter->consume()->isAccepted()) {
$this->logger->warning('IP address {ip} was rate limited by the Registration API.', [
'ip' => $request->getClientIp(),
]);
throw new TooManyRequestsHttpException();
}
$user = $this->serializer->deserialize($request->getContent(), User::class, 'json', ['groups' => 'user:register']);
if (null === $user->getEmail() || null === $user->getPassword()) {
throw new BadRequestHttpException('Bad request');
}
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$user->getPassword()
)
);
$this->em->persist($user);
$this->em->flush();
$this->logger->info('A new user has registered ({username}).', [
'username' => $user->getUserIdentifier(),
]);
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->locale('en')
->subject('Please Confirm your Email')
->htmlTemplate('emails/success/confirmation_email.html.twig')
);
return $this->redirectToRoute('index');
}
#[Route('/verify/email', name: 'app_verify_email')]
public function verifyUserEmail(Request $request, UserRepository $userRepository): Response
{
$id = $request->query->get('id');
if (null === $id) {
return $this->redirectToRoute('index');
}
$user = $userRepository->find($id);
if (null === $user) {
return $this->redirectToRoute('index');
}
$this->emailVerifier->handleEmailConfirmation($request, $user);
$this->logger->info('User {username} has validated his email address.', [
'username' => $user->getUserIdentifier(),
]);
return $this->redirectToRoute('index');
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Controller\MeController; use App\Controller\MeController;
use App\Controller\RegistrationController;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -26,6 +28,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => 'user:list'], normalizationContext: ['groups' => 'user:list'],
read: false read: false
), ),
new Post(
uriTemplate: '/register',
routeName: 'user_register',
controller: RegistrationController::class,
denormalizationContext: ['groups' => ['user:register']],
read: false,
name: 'register'
),
] ]
)] )]
class User implements UserInterface, PasswordAuthenticatedUserInterface class User implements UserInterface, PasswordAuthenticatedUserInterface
@@ -36,7 +46,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Groups(['user:list'])] #[Groups(['user:list', 'user:register'])]
private ?string $email = null; private ?string $email = null;
/** /**
@@ -50,6 +60,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var string|null The hashed password * @var string|null The hashed password
*/ */
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
#[Groups(['user:register'])]
private ?string $password = null; private ?string $password = null;
/** /**
@@ -64,6 +75,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: Connector::class, mappedBy: 'user', orphanRemoval: true)] #[ORM\OneToMany(targetEntity: Connector::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $connectors; private Collection $connectors;
#[ORM\Column]
private bool $isVerified = false;
public function __construct() public function __construct()
{ {
$this->watchLists = new ArrayCollection(); $this->watchLists = new ArrayCollection();
@@ -201,4 +215,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function isVerified(): bool
{
return $this->isVerified;
}
public function setVerified(bool $isVerified): static
{
$this->isVerified = $isVerified;
return $this;
}
} }

View File

@@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
#[AsMessageHandler] #[AsMessageHandler]
@@ -27,6 +28,7 @@ final readonly class ProcessDomainTriggerHandler
{ {
public function __construct( public function __construct(
private string $mailerSenderEmail, private string $mailerSenderEmail,
private string $mailerSenderName,
private MailerInterface $mailer, private MailerInterface $mailer,
private WatchListRepository $watchListRepository, private WatchListRepository $watchListRepository,
private DomainRepository $domainRepository, private DomainRepository $domainRepository,
@@ -97,7 +99,7 @@ final readonly class ProcessDomainTriggerHandler
private function sendEmailDomainOrdered(Domain $domain, Connector $connector, User $user): void private function sendEmailDomainOrdered(Domain $domain, Connector $connector, User $user): void
{ {
$email = (new TemplatedEmail()) $email = (new TemplatedEmail())
->from($this->mailerSenderEmail) ->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail()) ->to($user->getEmail())
->priority(Email::PRIORITY_HIGHEST) ->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been ordered') ->subject('A domain name has been ordered')
@@ -117,7 +119,7 @@ final readonly class ProcessDomainTriggerHandler
private function sendEmailDomainOrderError(Domain $domain, User $user): void private function sendEmailDomainOrderError(Domain $domain, User $user): void
{ {
$email = (new TemplatedEmail()) $email = (new TemplatedEmail())
->from($this->mailerSenderEmail) ->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail()) ->to($user->getEmail())
->subject('An error occurred while ordering a domain name') ->subject('An error occurred while ordering a domain name')
->htmlTemplate('emails/errors/domain_order.html.twig') ->htmlTemplate('emails/errors/domain_order.html.twig')
@@ -135,7 +137,7 @@ final readonly class ProcessDomainTriggerHandler
private function sendEmailDomainUpdated(DomainEvent $domainEvent, User $user): void private function sendEmailDomainUpdated(DomainEvent $domainEvent, User $user): void
{ {
$email = (new TemplatedEmail()) $email = (new TemplatedEmail())
->from($this->mailerSenderEmail) ->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail()) ->to($user->getEmail())
->priority(Email::PRIORITY_HIGHEST) ->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been changed') ->subject('A domain name has been changed')

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface; use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Address;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
#[AsMessageHandler] #[AsMessageHandler]
@@ -25,6 +26,7 @@ final readonly class ProcessWatchListTriggerHandler
private RDAPService $RDAPService, private RDAPService $RDAPService,
private MailerInterface $mailer, private MailerInterface $mailer,
private string $mailerSenderEmail, private string $mailerSenderEmail,
private string $mailerSenderName,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private WatchListRepository $watchListRepository, private WatchListRepository $watchListRepository,
private LoggerInterface $logger private LoggerInterface $logger
@@ -90,7 +92,7 @@ final readonly class ProcessWatchListTriggerHandler
private function sendEmailDomainUpdateError(Domain $domain, User $user): void private function sendEmailDomainUpdateError(Domain $domain, User $user): void
{ {
$email = (new TemplatedEmail()) $email = (new TemplatedEmail())
->from($this->mailerSenderEmail) ->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail()) ->to($user->getEmail())
->subject('An error occurred while updating a domain name') ->subject('An error occurred while updating a domain name')
->htmlTemplate('emails/errors/domain_update.html.twig') ->htmlTemplate('emails/errors/domain_update.html.twig')

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
readonly class EmailVerifier
{
public function __construct(
private VerifyEmailHelperInterface $verifyEmailHelper,
private MailerInterface $mailer,
private EntityManagerInterface $entityManager
) {
}
/**
* @throws TransportExceptionInterface
*/
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void
{
$signatureComponents = $this->verifyEmailHelper->generateSignature(
$verifyEmailRouteName,
(string) $user->getId(),
$user->getEmail(),
['id' => $user->getId()]
);
$context = $email->getContext();
$context['signedUrl'] = $signatureComponents->getSignedUrl();
$context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey();
$context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData();
$email->context($context);
$this->mailer->send($email);
}
public function handleEmailConfirmation(Request $request, User $user): void
{
$this->verifyEmailHelper->validateEmailConfirmationFromRequest($request, (string) $user->getId(), $user->getEmail());
$user->setVerified(true);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}

View File

@@ -58,7 +58,7 @@ class OAuthAuthenticator extends OAuth2Authenticator implements AuthenticationEn
} }
$user = new User(); $user = new User();
$user->setEmail($userFromToken->getEmail()); $user->setEmail($userFromToken->getEmail())->setVerified(true);
$this->em->persist($user); $this->em->persist($user);
$this->em->flush(); $this->em->flush();

View File

@@ -56,18 +56,21 @@
<h1>Domain Watchdog Error</h1> <h1>Domain Watchdog Error</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hello,</p> <p>Hello, <br/>
<p>We would like to inform you that an error occurred while ordering the following domain name:</p> We would like to inform you that an error occurred while ordering the following domain name:<br/>
<p><strong>Domain name:</strong> {{ domain.ldhName }}</p> <strong>Domain name:</strong> {{ domain.ldhName }}<br/>
</p>
<p>Here are some possible explanations:</p> <p>Here are some possible explanations:</p>
<ul> <ul>
<li>The Connector configuration you provided is no longer valid.</li> <li>The Connector configuration you provided is no longer valid.</li>
<li>It is likely that the domain is no longer available for registration.</li> <li>It is likely that the domain is no longer available for registration.</li>
<li>A temporary interruption is affecting the registration of this domain name.</li> <li>A temporary interruption is affecting the registration of this domain name.</li>
</ul> </ul>
<p>Thank you for your understanding,</p> <br/><br/>
<p>Sincerely,</p> <p>Thank you for your understanding,<br/>
<p>Domain Watchdog</p> Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; Domain Watchdog</p> <p>&copy; Domain Watchdog</p>

View File

@@ -56,18 +56,22 @@
<h1>Domain Watchdog Error</h1> <h1>Domain Watchdog Error</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hello,</p> <p>Hello, <br/>
<p>We would like to inform you that an error occurred while updating the information for the following domain We would like to inform you that an error occurred while updating the information for the following domain
name:</p> name:<br/>
<p><strong>Domain name:</strong> {{ domain.ldhName }}</p> <strong>Domain name:</strong> {{ domain.ldhName }}<br/>
</p>
<p>Here are some possible explanations:</p> <p>Here are some possible explanations:</p>
<ul> <ul>
<li>It is likely that the domain will be available for registration again.</li> <li>It is likely that the domain will be available for registration again.</li>
<li>A temporary outage affects the provision of domain name information.</li> <li>A temporary outage affects the provision of domain name information.</li>
</ul> </ul>
<p>Thank you for your understanding,</p> <br/><br/>
<p>Sincerely,</p> <p>Thank you for your understanding,<br/>
<p>Domain Watchdog</p> Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; Domain Watchdog</p> <p>&copy; Domain Watchdog</p>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
color: #0056b3;
}
.content {
margin-bottom: 20px;
}
.content p {
margin: 0 0 10px;
}
.footer {
text-align: center;
color: #777;
}
</style>
<title>Domain Watchdog - Confirm Email</title>
</head>
<body>
<div class="container">
<div class="header">
<h1>Domain Watchdog - Confirm Email</h1>
</div>
<div class="content">
<p>Hello, <br/>
Please confirm your email address by clicking the following link: <br><br>
<a href="{{ signedUrl|raw }}">Confirm my Email</a>.
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
<br/><br/>
Thank you for your understanding,<br/>
Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div>
<div class="footer">
<p>&copy; Domain Watchdog</p>
</div>
</div>
</body>
</html>

View File

@@ -48,15 +48,16 @@
<h1>Domain Watchdog - Domain Ordered</h1> <h1>Domain Watchdog - Domain Ordered</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hello,</p> <p>Hello, <br/>
<p>We are pleased to inform you that a domain name present in your Watchlist has been ordered using the We are pleased to inform you that a domain name present in your Watchlist has been ordered using the
connector you have chosen.</p> connector you have chosen.<br/>
<p><strong>Domain name:</strong> {{ domain.ldhName }}</p> <strong>Domain name:</strong> {{ domain.ldhName }}<br/>
<p><strong>Connector provider :</strong> {{ provider }}</p> <strong>Connector provider :</strong> {{ provider }}<br/>
<br/> <br/><br/>
<p>Thank you for your understanding,</p> Thank you for your understanding,<br/>
<p>Sincerely,</p> Sincerely,<br/>
<p>Domain Watchdog</p> Domain Watchdog<br/>
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; Domain Watchdog</p> <p>&copy; Domain Watchdog</p>

View File

@@ -48,15 +48,16 @@
<h1>Domain Watchdog Alert</h1> <h1>Domain Watchdog Alert</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hello,</p> <p>Hello, <br/>
<p>We are pleased to inform you that a new action has been detected on a domain name in your watchlist.</p> We are pleased to inform you that a new action has been detected on a domain name in your watchlist.<br/>
<p><strong>Domain name:</strong> {{ event.domain.ldhName }}</p> <strong>Domain name:</strong> {{ event.domain.ldhName }}<br/>
<p><strong>Action:</strong> {{ event.action }}</p> <strong>Action:</strong> {{ event.action }}<br/>
<p><strong>Effective Date:</strong> {{ event.date | date("c") }}</p> <strong>Effective Date:</strong> {{ event.date | date("c") }}<br/>
<br/> <br/><br/>
<p>Thank you for your understanding,</p> Thank you for your understanding,<br/>
<p>Sincerely,</p> Sincerely,<br/>
<p>Domain Watchdog</p> Domain Watchdog<br/>
</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; Domain Watchdog</p> <p>&copy; Domain Watchdog</p>