From ff358945f2b210f7f374d3e3e7e36170ee16aed5 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Tue, 3 Nov 2020 06:16:17 +0100 Subject: KB68 Newsletter registration and deregistration. Change-Id: Ic79fe64f03ce887879bffc7623e59cb697065ee6 --- .../mulk/mulkcms2/benki/newsletter/MailRouter.java | 32 +++++++++ .../benki/newsletter/NewsletterResource.java | 76 ++++++++++++++++++++++ .../benki/newsletter/NewsletterSender.java | 2 +- .../benki/newsletter/NewsletterSubscription.java | 17 ++++- .../benki/newsletter/NewsletterUnsubscriber.java | 63 ++++++++++++++++++ 5 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/newsletter/MailRouter.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterResource.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterUnsubscriber.java (limited to 'src/main/java') 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 register(@FormParam("email") String email) { + var existingSubscription = + 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.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 @@ public class NewsletterSender { posts.forEach(post -> post.newsletter = newsletter); var subscriberEmails = - NewsletterSubscription.streamAll() + 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 @@ import org.hibernate.annotations.NaturalId; @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 @@ public class NewsletterSubscription extends PanacheEntityBase { @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.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())); + } + } +} -- cgit v1.2.3