KB68 Newsletter registration and deregistration.

Change-Id: Ic79fe64f03ce887879bffc7623e59cb697065ee6
diff --git a/pom.xml b/pom.xml
index ab3f5ac..e1d2b4e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,8 +23,7 @@
     <resources-plugin.version>3.2.0</resources-plugin.version>
     <spotless-plugin.version>2.4.1</spotless-plugin.version>
 
-    <!-- <quarkus.platform.artifact-id>quarkus-universe-bom</quarkus.platform.artifact-id> -->
-    <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
+    <quarkus.platform.artifact-id>quarkus-universe-bom</quarkus.platform.artifact-id>
     <quarkus.platform.group-id>io.quarkus</quarkus.platform.group-id>
 
     <basic-annotations.version>0.2.0</basic-annotations.version>
@@ -116,6 +115,7 @@
 
   <dependencies>
 
+    <!-- Quarkus -->
     <dependency>
       <groupId>io.quarkus</groupId>
       <artifactId>quarkus-agroal</artifactId>
@@ -158,6 +158,10 @@
     </dependency>
     <dependency>
       <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-resteasy-mutiny</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.quarkus</groupId>
       <artifactId>quarkus-resteasy-qute</artifactId>
     </dependency>
     <dependency>
@@ -177,6 +181,13 @@
       <artifactId>quarkus-container-image-jib</artifactId>
     </dependency>
 
+
+    <!-- Quarkus universe -->
+    <dependency>
+      <groupId>org.apache.camel.quarkus</groupId>
+      <artifactId>camel-quarkus-mail</artifactId>
+    </dependency>
+
     <!-- JNA -->
     <dependency>
       <groupId>net.java.dev.jna</groupId>
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/MailRouter.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/MailRouter.java
new file mode 100644
index 0000000..1d6ea5c
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/MailRouter.java
@@ -0,0 +1,32 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import javax.enterprise.context.Dependent;
+import javax.inject.Inject;
+import org.apache.camel.builder.RouteBuilder;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+@Dependent
+public class MailRouter extends RouteBuilder {
+
+  @ConfigProperty(name = "quarkus.mailer.host")
+  String emailHost;
+
+  @ConfigProperty(name = "mulkcms.imap.port")
+  int emailPort;
+
+  @ConfigProperty(name = "quarkus.mailer.username")
+  String emailUser;
+
+  @ConfigProperty(name = "quarkus.mailer.password")
+  String emailPassword;
+
+  @Inject NewsletterUnsubscriber newsletterUnsubscriber;
+
+  @Override
+  public void configure() {
+    fromF(
+            "imaps://%s:%d?password=%s&username=%s&searchTerm.to=unsubscribe",
+            emailHost, emailPort, emailPassword, emailUser)
+        .process(newsletterUnsubscriber);
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterResource.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterResource.java
new file mode 100644
index 0000000..a46ee32
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterResource.java
@@ -0,0 +1,76 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import static javax.ws.rs.core.MediaType.TEXT_HTML;
+
+import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
+import io.quarkus.qute.TemplateInstance;
+import io.quarkus.qute.api.CheckedTemplate;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import javax.transaction.Transactional;
+import javax.ws.rs.ClientErrorException;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Response.Status;
+
+@Path("/newsletter")
+@Produces(TEXT_HTML)
+public class NewsletterResource {
+
+  @CheckedTemplate
+  static class Templates {
+    public static native MailTemplateInstance registrationMail(String registrationKey);
+
+    public static native TemplateInstance index();
+
+    public static native TemplateInstance completeRegistration();
+
+    public static native TemplateInstance registered();
+  }
+
+  @GET
+  public TemplateInstance getIndex() {
+    return Templates.index();
+  }
+
+  @POST
+  @Path("register")
+  @Transactional
+  public CompletionStage<TemplateInstance> register(@FormParam("email") String email) {
+    var existingSubscription =
+        NewsletterSubscription.<NewsletterSubscription>find("email = ?1", email)
+            .singleResultOptional();
+    if (existingSubscription.isPresent()) {
+      // If a subscription already exists, act as if we had created it.  This provides better
+      // privacy to users than an error message does.
+      return CompletableFuture.completedStage(Templates.completeRegistration());
+    }
+
+    var subscription = new NewsletterSubscription();
+    subscription.email = email;
+    subscription.persist();
+
+    var mailText = Templates.registrationMail(subscription.registrationKey);
+    var sendJob = mailText.subject("MulkCMS newsletter registration").to(email).send();
+    return sendJob.thenApply((x) -> Templates.completeRegistration());
+  }
+
+  @GET
+  @Path("finish-registration")
+  @Transactional
+  public TemplateInstance finishRegistration(@QueryParam("key") String registrationKey) {
+    NewsletterSubscription.<NewsletterSubscription>find("registrationKey = ?1", registrationKey)
+        .singleResultOptional()
+        .ifPresentOrElse(
+            s -> s.registrationKey = null,
+            () -> {
+              throw new ClientErrorException(Status.BAD_REQUEST);
+            });
+
+    return Templates.registered();
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java
index 1f13c08..59ab37c 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java
@@ -84,7 +84,7 @@
     posts.forEach(post -> post.newsletter = newsletter);
 
     var subscriberEmails =
-        NewsletterSubscription.<NewsletterSubscription>streamAll()
+        NewsletterSubscription.<NewsletterSubscription>stream("registrationKey IS NULL")
             .map(x -> x.email)
             .toArray(String[]::new);
 
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java
index 7aeda60..cd50b2e 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java
@@ -1,10 +1,13 @@
 package eu.mulk.mulkcms2.benki.newsletter;
 
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import java.math.BigInteger;
+import java.security.SecureRandom;
 import java.time.OffsetDateTime;
 import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
 import javax.persistence.Id;
 import javax.persistence.Table;
 import javax.validation.constraints.Email;
@@ -14,9 +17,11 @@
 @Table(name = "newsletter_subscriptions", schema = "benki")
 public class NewsletterSubscription extends PanacheEntityBase {
 
+  private static final int registrationKeyBytes = 32;
+
   @Id
   @Column(name = "id", nullable = false)
-  @GeneratedValue
+  @GeneratedValue(strategy = GenerationType.IDENTITY)
   public Integer id;
 
   @Column(name = "start_date", nullable = false)
@@ -26,4 +31,14 @@
   @Column(name = "email", nullable = false)
   @Email
   public String email;
+
+  @Column(name = "registration_key", nullable = true)
+  public String registrationKey = generateRegistrationKey();
+
+  private static String generateRegistrationKey() {
+    var secureRandom = new SecureRandom();
+    byte[] keyBytes = new byte[registrationKeyBytes];
+    secureRandom.nextBytes(keyBytes);
+    return new BigInteger(keyBytes).abs().toString(36);
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterUnsubscriber.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterUnsubscriber.java
new file mode 100644
index 0000000..0a67ff6
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterUnsubscriber.java
@@ -0,0 +1,63 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
+import io.quarkus.qute.api.CheckedTemplate;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.enterprise.context.Dependent;
+import javax.mail.internet.InternetAddress;
+import javax.transaction.Transactional;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.component.mail.MailMessage;
+import org.jboss.logging.Logger;
+
+@Dependent
+public class NewsletterUnsubscriber implements Processor {
+
+  private static final Logger log = Logger.getLogger(NewsletterUnsubscriber.class);
+
+  @CheckedTemplate
+  static class Templates {
+    public static native MailTemplateInstance unsubscribedMail();
+  }
+
+  @Override
+  @Transactional
+  public void process(Exchange exchange) throws Exception {
+    var message = exchange.getMessage(MailMessage.class);
+    var mail = message.getMessage();
+
+    for (var sender : mail.getFrom()) {
+      if (!(sender instanceof InternetAddress)) {
+        log.warnf("Tried to unsubscribe, but not an InternetAddress: %s", sender);
+        continue;
+      }
+
+      var address = ((InternetAddress) sender).getAddress();
+      var subscription =
+          NewsletterSubscription.<NewsletterSubscription>find("email = ?1", address)
+              .singleResultOptional();
+      subscription.ifPresentOrElse(
+          s -> {
+            try {
+              var sendJob =
+                  Templates.unsubscribedMail()
+                      .subject("Unsubscribed from MulkCMS newsletter")
+                      .to(address)
+                      .send();
+              sendJob.toCompletableFuture().get(60, TimeUnit.SECONDS);
+
+              s.delete();
+
+              log.infof("Unsubscribed: %s (#%d)", s.email, s.id);
+            } catch (InterruptedException | ExecutionException | TimeoutException e) {
+              throw new RuntimeException(e);
+            }
+          },
+          () ->
+              log.warnf("Tried to unsubscribe, but no subscription found: %s", sender.toString()));
+    }
+  }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index b7c54da..088e4e0 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -68,6 +68,12 @@
 quarkus.mailer.port = 587
 quarkus.mailer.start-tls = REQUIRED
 quarkus.mailer.username = mulkcms@benkard.de
+mulkcms.imap.port = 993
+
+%dev.quarkus.mailer.host = mail.benkard.de
+%dev.quarkus.mailer.from = test@benkard.de
+%dev.quarkus.mailer.username = test@benkard.de
+%dev.quarkus.mailer.password = test
 
 # Deployment
 docker.registry = docker.benkard.de
diff --git a/src/main/resources/db/changeLog-1.7.xml b/src/main/resources/db/changeLog-1.7.xml
index 8824115..cfdad49 100644
--- a/src/main/resources/db/changeLog-1.7.xml
+++ b/src/main/resources/db/changeLog-1.7.xml
@@ -52,4 +52,18 @@
     </createTable>
   </changeSet>
 
+  <changeSet author="mulk" id="1.7-3">
+    <addColumn tableName="newsletter_subscriptions" schemaName="benki">
+      <column name="registration_key" type="VARCHAR">
+        <constraints unique="true" uniqueConstraintName="newsletter_subscriptions_registration_key_key"/>
+      </column>
+    </addColumn>
+
+    <createIndex tableName="newsletter_subscriptions"
+                 schemaName="benki"
+                 indexName="newsletter_subscriptions_registration_key_idx">
+      <column name="registration_key"/>
+    </createIndex>
+  </changeSet>
+
 </databaseChangeLog>
diff --git a/src/main/resources/templates/NewsletterResource/completeRegistration.html b/src/main/resources/templates/NewsletterResource/completeRegistration.html
new file mode 100644
index 0000000..0cdb874
--- /dev/null
+++ b/src/main/resources/templates/NewsletterResource/completeRegistration.html
@@ -0,0 +1,24 @@
+{#include base.html}
+
+{#title}Newsletter Registration &#8212; Benki{/title}
+{#siteSection}Newsletter{/siteSection}
+{#wikiClass}this-page{/wikiClass}
+
+{#nav}{#navbar siteSection="Newsletter" /}
+
+{#head}{/head}
+
+{#body}
+<article id="newsletter-registration">
+  <header>
+    <h1>Newsletter Registration</h1>
+  </header>
+
+  <section>
+    <p>In order to complete your registration, please check your email inbox and click
+      the link in the confirmation mail.</p>
+  </section>
+</article>
+{/body}
+
+{/include}
diff --git a/src/main/resources/templates/NewsletterResource/index.html b/src/main/resources/templates/NewsletterResource/index.html
new file mode 100644
index 0000000..84098bd
--- /dev/null
+++ b/src/main/resources/templates/NewsletterResource/index.html
@@ -0,0 +1,32 @@
+{#include base.html}
+
+{#title}Newsletter &#8212; Benki{/title}
+{#siteSection}Newsletter{/siteSection}
+{#wikiClass}this-page{/wikiClass}
+
+{#nav}{#navbar siteSection="Newsletter" /}
+
+{#head}{/head}
+
+{#body}
+<article id="newsletter-registration">
+  <header>
+    <h1>Newsletter</h1>
+  </header>
+
+  <section>
+    <p>To get a weekly digest of all posted articles and bookmarks into your email inbox,
+      enter your email address and submit the form below.</p>
+
+    <p>Your registration is subject to the <a href="/privacy">privacy policy</a>.</p>
+
+    <form class="pure-form pure-form-aligned" action="/newsletter/register" method="post">
+      <label for="email-input">E-mail address: </label>
+      <input type="email" name="email" id="email-input" placeholder="E-mail address" required/>
+      <input class="pure-button pure-button-primary" type="submit" value="Subscribe"/>
+    </form>
+  </section>
+</article>
+{/body}
+
+{/include}
diff --git a/src/main/resources/templates/NewsletterResource/registered.html b/src/main/resources/templates/NewsletterResource/registered.html
new file mode 100644
index 0000000..e82618e
--- /dev/null
+++ b/src/main/resources/templates/NewsletterResource/registered.html
@@ -0,0 +1,26 @@
+{#include base.html}
+
+{#title}Newsletter Registration &#8212; Benki{/title}
+{#siteSection}Newsletter{/siteSection}
+{#wikiClass}this-page{/wikiClass}
+
+{#nav}{#navbar siteSection="Newsletter" /}
+
+{#head}{/head}
+
+{#body}
+<article id="newsletter-registration">
+  <header>
+    <h1>Newsletter Registration</h1>
+  </header>
+
+  <section>
+    <p>Thank you. You are now subscribed to the weekly email digest.</p>
+
+    <p>To unsubscribe, send an email to
+      <a href="mailto:mulkcms+unsubscribe@benkard.de">mulkcms+unsubscribe@benkard.de</a>.</p>
+  </section>
+</article>
+{/body}
+
+{/include}
diff --git a/src/main/resources/templates/NewsletterResource/registrationMail.txt b/src/main/resources/templates/NewsletterResource/registrationMail.txt
new file mode 100644
index 0000000..c86b385
--- /dev/null
+++ b/src/main/resources/templates/NewsletterResource/registrationMail.txt
@@ -0,0 +1,12 @@
+{@java.lang.String registrationKey}
+Hello!
+
+Someone (you, one would hope) entered your email address to subscribe to the MulkCMS
+benkard.de newsletter.  In order to complete your registration, open the following
+link in your web browser:
+
+    https://matthias.benkard.de/newsletter/finish-registration?key={registrationKey}
+
+If someone is playing a trick on you and you would not actually like to subscribe to
+the newsletter, you may ignore this email.  In this case, your registration record
+will be deleted within a week.
\ No newline at end of file
diff --git a/src/main/resources/templates/NewsletterUnsubscriber/unsubscribedMail.txt b/src/main/resources/templates/NewsletterUnsubscriber/unsubscribedMail.txt
new file mode 100644
index 0000000..56f8d0e
--- /dev/null
+++ b/src/main/resources/templates/NewsletterUnsubscriber/unsubscribedMail.txt
@@ -0,0 +1,7 @@
+You have been unsubscribed from the MulkCMS newsletter because we
+received an unsubscription request from your email address.
+
+To register for the newsletter again, open the following page in
+your web browser:
+
+  https://matthias.benkard.de/newsletter
diff --git a/src/main/resources/templates/tags/navbar.html b/src/main/resources/templates/tags/navbar.html
index 63f88f1..d79a952 100644
--- a/src/main/resources/templates/tags/navbar.html
+++ b/src/main/resources/templates/tags/navbar.html
@@ -9,6 +9,8 @@
   <li class='{#if siteSection == "Wiki"}this-page{/}' data-site-section="Wiki"><a href="/wiki/Home">Wiki</a></li>
   {/if}
 
+  <li class='{#if siteSection == "Newsletter"}this-page{/}' data-site-section="Newsletter"><a href="/newsletter">Newsletter</a></li>
+
   <li class='{#if siteSection == "About"}this-page{/}' data-site-section="About"><a href="/about">Contact Info</a></li>
   <li class='{#if siteSection == "Privacy"}this-page{/}' data-site-section="Privacy"><a href="/privacy">Privacy Policy</a></li>