KB68 Newsletter registration and deregistration.

Change-Id: Ic79fe64f03ce887879bffc7623e59cb697065ee6
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()));
+    }
+  }
+}