Skip to content

Commit

Permalink
implement DKIM (#117)
Browse files Browse the repository at this point in the history
  • Loading branch information
breed authored Aug 12, 2023
1 parent b127de0 commit bacea60
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 19 deletions.
5 changes: 5 additions & 0 deletions server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
<artifactId>simple-java-mail</artifactId>
<version>8.1.3</version>
</dependency>
<dependency>
<groupId>org.simplejavamail</groupId>
<artifactId>dkim-module</artifactId>
<version>8.1.3</version>
</dependency>
<dependency>
<groupId>net.mailific</groupId>
<artifactId>mailific-serverlib</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@
import reactor.core.publisher.Mono;

public interface PubKeyPairRepository extends ReactiveMongoRepository<PubKeyPair, String> {
// we are putting the DKIM key into the database with accounts, so use a name that cannot be an account
// spaces normally get trimmed, and emojis can't be used in the account name
String DKIM_ACCT_ID = " 😀DKIM😀 ";

Mono<PubKeyPair> findItemByAcct(String acct);
}
73 changes: 56 additions & 17 deletions server/src/main/java/edu/sjsu/moth/server/service/EmailService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import edu.sjsu.moth.server.db.EmailRegistration;
import edu.sjsu.moth.server.db.EmailRegistrationRepository;
import edu.sjsu.moth.server.db.PubKeyPairRepository;
import edu.sjsu.moth.server.util.MothConfiguration;
import edu.sjsu.moth.server.util.Util;
import edu.sjsu.moth.util.EmailCodeUtils;
Expand All @@ -26,7 +27,8 @@
import net.mailific.server.session.Reply;
import net.mailific.server.session.SmtpSession;
import org.jetbrains.annotations.NotNull;
import org.simplejavamail.api.email.Email;
import org.simplejavamail.api.email.EmailPopulatingBuilder;
import org.simplejavamail.api.email.config.DkimConfig;
import org.simplejavamail.api.mailer.Mailer;
import org.simplejavamail.api.mailer.config.TransportStrategy;
import org.simplejavamail.email.EmailBuilder;
Expand All @@ -38,6 +40,7 @@
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;

import java.util.Base64;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
Expand All @@ -60,11 +63,17 @@ public class EmailService implements ApplicationRunner {
@Autowired
EmailRegistrationRepository emailRegistrationRepository;

// needed to get the DKIM private key
@Autowired
PubKeyPairRepository pubKeyPairRepository;

@Autowired
MessageSource messageSource;

String domain;
List<BiFunction<String, String, Boolean>> listeners = Collections.synchronizedList(new LinkedList<>());
byte[] dkimPrivateKey;
String dkimPublicKeyBase64;

static public String extractEmail(String from) {
var startBracket = from.lastIndexOf('<');
Expand All @@ -75,8 +84,26 @@ static public String extractEmail(String from) {
return from.substring(startBracket + 1, endBracket);
}

public static String unPEM(String pem) {
return pem.replaceAll("--+[^-]+--+", "").replace("\n", "").replace("\r", "");
}

@Override
public void run(ApplicationArguments args) throws Exception {
var dkimPubKeyPair = pubKeyPairRepository.findItemByAcct(PubKeyPairRepository.DKIM_ACCT_ID).block();
if (dkimPubKeyPair == null) {
dkimPubKeyPair = pubKeyPairRepository.save(AccountService.genPubKeyPair(PubKeyPairRepository.DKIM_ACCT_ID))
.block();
if (dkimPubKeyPair == null) {
log.error("❌ could not create DKIM public key");
System.exit(2);
}
log.info("❗ created DKIM public key");
}
dkimPrivateKey = Base64.getDecoder().decode(unPEM(dkimPubKeyPair.privateKeyPEM));
dkimPublicKeyBase64 = unPEM(dkimPubKeyPair.publicKeyPEM);
log.info("✅ found DKIM public key: " + dkimPublicKeyBase64);

var cfg = MothConfiguration.mothConfiguration;
domain = cfg.getServerName();
if (cfg.getSMTPLocalPort() == -1 || cfg.getSMTPServerHost() == null) {
Expand Down Expand Up @@ -157,8 +184,13 @@ public void listenForEmail(BiFunction<String, String, Boolean> receive) {
listeners.add(receive);
}

public @NotNull CompletableFuture<Void> sendMail(Email email) {
return mailer.sendMail(email);
public @NotNull CompletableFuture<Void> sendMail(EmailPopulatingBuilder emailBuilder) {
var dkimCfg = DkimConfig.builder()
.dkimPrivateKeyData(dkimPrivateKey)
.dkimSelector("moth")
.dkimSigningDomain(MothConfiguration.mothConfiguration.getServerName())
.build();
return mailer.sendMail(emailBuilder.signWithDomainKey(dkimCfg).buildEmail());
}

/**
Expand All @@ -185,13 +217,16 @@ private class MyBaseMailObject extends BaseMailObject {
String to;
String from;
String subject;
String firstLine = "";
String lastLine = "";
boolean blankSeen;
boolean inReplyToSeen;

@Override
public void writeLine(byte[] line, int offset, int length) {
public void writeLine(byte[] lineBytes, int offset, int length) {
var line = new String(lineBytes, offset, length);
if (!blankSeen) {
var parts = new String(line, offset, length).split(":", 2);
var parts = line.split(":", 2);
switch (parts[0].toLowerCase()) {
case "from" -> from = parts[1].strip();
case "to" -> to = parts[1].strip();
Expand All @@ -200,13 +235,21 @@ public void writeLine(byte[] line, int offset, int length) {
case "in-reply-to" -> inReplyToSeen = true;
case "" -> blankSeen = true;
}
} else {
if (firstLine.isEmpty()) firstLine = line;
lastLine = line;
}
}

@Override
public Reply complete(SmtpSession session) {
// only reply to fresh messages that don't talk about deamons or failures
log.info("received mail from %s to %s about %s".formatted(from, to, subject));
if (!to.contains(AuthService.registrationEmail())) {
log.warn("ignoring mail from: %s, to: %s, subject: %s, body=%s..%s".formatted(from, to, subject,
firstLine, lastLine));

}
if (!inReplyToSeen && !from.contains("mailer-daemon") && !subject.contains("ailure")) {
// this will notify all the listeners and remove them if they are done listening
listeners.removeIf(f -> !f.apply(from, subject));
Expand All @@ -219,41 +262,37 @@ public Reply complete(SmtpSession session) {
var registration = subject.contains("regist");
var reset = subject.contains("reset");
var replySubject = "re: " + subject;
Mono<Email> mono;
Mono<EmailPopulatingBuilder> mono;
if (registration == reset) {
mono = Mono.just(eb.withSubject(replySubject)
.withPlainText(getMessage("emailUnrecognizedReply"))
.buildEmail());
mono = Mono.just(eb.withSubject(replySubject).withPlainText(getMessage("emailUnrecognizedReply")));
} else {
mono = emailRegistrationRepository.findById(EmailCodeUtils.normalizeEmail(emailId)).flatMap(reg -> {
if (registration) {
eb.withSubject(replySubject).withPlainText(getMessage("emailAlreadyRegistered"));
return Mono.just(eb.buildEmail());
return Mono.just(eb);
} else {
var password = Util.generatePassword();
reg.saltedPassword = EmailCodeUtils.encodePassword(password);
reg.lastEmail = EmailCodeUtils.now();
return emailRegistrationRepository.save(reg)
.thenReturn(eb.withSubject(getMessage("emailSubjectPasswordReset"))
.withPlainText(getMessage("emailNewPassword", password))
.buildEmail());
.withPlainText(getMessage("emailNewPassword", password)));
}
}).switchIfEmpty(Mono.defer(() -> {
if (registration) {
var password = Util.generatePassword();
return registerEmail(emailId, password).thenReturn(
eb.withSubject(getMessage("emailSubjectWelcome", domain))
.withPlainText(getMessage("emailNewPassword", password))
.buildEmail());
.withPlainText(getMessage("emailNewPassword", password)));
} else {
eb.withSubject(replySubject).withPlainText(getMessage("emailNotRegistered"));
return Mono.just(eb.buildEmail());
return Mono.just(eb);

}
}));
}
mono.doOnNext(email -> log.info("sending " + email.getSubject() + " to " + email.getToRecipients()))
.map(mailer::sendMail)
mono.doOnNext(email -> log.info("sending " + email.getSubject() + " to " + email.getRecipients()))
.map(EmailService.this::sendMail)
.block();
}
return super.complete(session);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ public void run(ApplicationArguments args) {
.to(AuthService.registrationEmail())
.from(AuthService.registrationEmail())
.withSubject(randSubject)
.withPlainText("checking email")
.buildEmail());
.withPlainText("checking email"));
var responseSeen = false;
try {
responseSeen = latch.await(20, TimeUnit.SECONDS);
Expand Down

0 comments on commit bacea60

Please sign in to comment.