package com.mailserver.delivery; import jakarta.mail.Address; import jakarta.mail.AuthenticationFailedException; import jakarta.mail.MessagingException; import jakarta.mail.SendFailedException; import jakarta.mail.Session; import jakarta.mail.Transport; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; import org.eclipse.angus.mail.smtp.SMTPAddressFailedException; import org.eclipse.angus.mail.smtp.SMTPSendFailedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xbill.DNS.Lookup; import org.xbill.DNS.MXRecord; import org.xbill.DNS.Record; import org.xbill.DNS.TextParseException; import org.xbill.DNS.Type; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.OutputStream; import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Properties; /** * Delivers one message to one recipient over SMTP (Jakarta Mail), blocking. * * The message bytes are sent exactly as given (they carry the DKIM signature). */ public class SmtpDeliveryClient { private static final Logger log = LoggerFactory.getLogger(SmtpDeliveryClient.class); public enum Mode { OFF, MX, SMARTHOST } public enum Tls { NONE, STARTTLS, SSL } /** port — of the smarthost; mxPort — of recipients' MX servers (25; another value only in tests). */ public record Settings(Mode mode, String host, int port, String username, String password, Tls tls, String heloName, int timeoutMs, int mxPort) { } public enum Outcome { DELIVERED, TEMPORARY, PERMANENT } public record Result(Outcome outcome, String detail) { static Result delivered(String detail) { return new Result(Outcome.DELIVERED, detail); } static Result temporary(String detail) { return new Result(Outcome.TEMPORARY, detail); } static Result permanent(String detail) { return new Result(Outcome.PERMANENT, detail); } } /** MX hosts of a domain, best first; empty — the domain has no mail server. */ public interface MxResolver { List resolve(String domain) throws TemporaryDnsException; } public static class TemporaryDnsException extends Exception { public TemporaryDnsException(String message) { super(message); } } private final Settings settings; private final MxResolver resolver; public SmtpDeliveryClient(Settings settings) { this(settings, SmtpDeliveryClient::dnsMx); } public SmtpDeliveryClient(Settings settings, MxResolver resolver) { this.settings = settings; this.resolver = resolver; } public Mode mode() { return settings.mode(); } public Result deliver(String from, String recipient, byte[] message) { return switch (settings.mode()) { case OFF -> Result.permanent("outbound delivery is disabled on this server"); case SMARTHOST -> send(settings.host(), settings.port(), true, from, recipient, message); case MX -> viaMx(from, recipient, message); }; } private Result viaMx(String from, String recipient, byte[] message) { String domain = recipient.substring(recipient.lastIndexOf('@') + 1).toLowerCase(Locale.ROOT); List hosts; try { hosts = resolver.resolve(domain); } catch (TemporaryDnsException e) { return Result.temporary("DNS: " + e.getMessage()); } if (hosts.isEmpty()) { return Result.permanent("domain " + domain + " does not accept mail (no MX/A record)"); } Result last = null; for (String host : hosts) { last = send(host, settings.mxPort(), false, from, recipient, message); // A server answered with a decision — do not ask the next MX; only a connection problem moves on if (last.outcome() != Outcome.TEMPORARY || last.detail().startsWith("4")) { return last; } } return last; } private Result send(String host, int port, boolean smarthost, String from, String recipient, byte[] message) { Properties props = new Properties(); props.put("mail.smtp.host", host); props.put("mail.smtp.port", String.valueOf(port)); props.put("mail.smtp.localhost", settings.heloName()); props.put("mail.smtp.connectiontimeout", String.valueOf(settings.timeoutMs())); props.put("mail.smtp.timeout", String.valueOf(settings.timeoutMs())); props.put("mail.smtp.writetimeout", String.valueOf(settings.timeoutMs())); props.put("mail.smtp.from", from); props.put("mail.smtp.allow8bitmime", "true"); boolean login = false; if (smarthost) { switch (settings.tls()) { case STARTTLS -> { props.put("mail.smtp.starttls.enable", "true"); props.put("mail.smtp.starttls.required", "true"); } case SSL -> props.put("mail.smtp.ssl.enable", "true"); case NONE -> { } } props.put("mail.smtp.ssl.checkserveridentity", "true"); login = settings.username() != null && !settings.username().isBlank(); props.put("mail.smtp.auth", String.valueOf(login)); } else { props.put("mail.smtp.starttls.enable", "true"); props.put("mail.smtp.ssl.trust", "*"); } Session session = Session.getInstance(props); try (Transport transport = session.getTransport(settings.tls() == Tls.SSL && smarthost ? "smtps" : "smtp")) { if (login) { transport.connect(host, port, settings.username(), settings.password()); } else { transport.connect(host, port, null, null); } transport.sendMessage(new RawMessage(session, message), new Address[]{new InternetAddress(recipient)}); log.info("Delivered {} -> {} via {}:{}", from, recipient, host, port); return Result.delivered(host); } catch (AuthenticationFailedException e) { return Result.temporary("login to " + host + " failed: " + e.getMessage()); } catch (MessagingException e) { int code = replyCode(e); String text = code > 0 ? code + " " + oneLine(e) : "connection to " + host + ": " + oneLine(e); log.warn("Delivery {} -> {} via {}: {}", from, recipient, host, text); return code >= 500 ? Result.permanent(text) : Result.temporary(text); } } /** SMTP reply code from the exception chain, 0 — no reply (connection problem). */ static int replyCode(Exception e) { Exception current = e; while (current != null) { if (current instanceof SMTPAddressFailedException f) { return f.getReturnCode(); } if (current instanceof SMTPSendFailedException f) { return f.getReturnCode(); } current = current instanceof MessagingException m ? m.getNextException() : current.getCause() instanceof Exception c ? c : null; if (current instanceof SendFailedException sf && sf.getInvalidAddresses() != null && sf.getNextException() == null) { return 550; } } return 0; } private static String oneLine(Exception e) { String message = String.valueOf(e.getMessage()).replaceAll("\\s+", " ").trim(); return message.length() > 500 ? message.substring(0, 500) : message; } /** MX hosts by preference (RFC 5321 5.1); no MX — the domain itself (implicit MX); null MX "." — none. */ static List dnsMx(String domain) throws TemporaryDnsException { try { Lookup lookup = new Lookup(domain, Type.MX); Record[] records = lookup.run(); switch (lookup.getResult()) { case Lookup.SUCCESSFUL -> { List hosts = Arrays.stream(records) .map(MXRecord.class::cast) .sorted(Comparator.comparingInt(MXRecord::getPriority)) .map(mx -> mx.getTarget().toString(true)) .filter(host -> !host.isEmpty() && !host.equals(".")) .toList(); return hosts; } case Lookup.TYPE_NOT_FOUND -> { Lookup a = new Lookup(domain, Type.A); a.run(); return a.getResult() == Lookup.SUCCESSFUL ? List.of(domain) : List.of(); } case Lookup.HOST_NOT_FOUND -> { return List.of(); } default -> throw new TemporaryDnsException(lookup.getErrorString()); } } catch (TextParseException e) { return List.of(); } } /** A message written to the wire byte for byte as received (keeps the DKIM signature valid). */ static final class RawMessage extends MimeMessage { private final byte[] raw; RawMessage(Session session, byte[] raw) throws MessagingException { super(session, new ByteArrayInputStream(raw)); this.raw = raw; } @Override public void writeTo(OutputStream os, String[] ignoreHeaders) throws IOException { os.write(raw); } @Override public void writeTo(OutputStream os) throws IOException { os.write(raw); } @Override public int getSize() { return raw.length; } } }