package com.mailserver.service; import com.mailserver.delivery.DeliveryWorker; import com.mailserver.delivery.OutboundMessage; import jakarta.mail.BodyPart; import jakarta.mail.MessagingException; import jakarta.mail.Multipart; import jakarta.mail.Part; import jakarta.mail.Session; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeUtility; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Properties; /** * Stores a message in one of our mailboxes. Mail from outside is checked first — DKIM (signature), * SPF (the connecting IP against the envelope sender's domain), DMARC (policy of the From domain); * a failure marks it as spam. Mail from our own logged-in users is trusted. */ @Service public class LocalDelivery implements DeliveryWorker.BounceSender { private static final Logger log = LoggerFactory.getLogger(LocalDelivery.class); private final MailService mailService; private final DkimService dkimService; private final SpfService spfService; private final DmarcService dmarcService; private final String hostname; private final MessageSources sources; public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService, DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname, MessageSources sources) { this.sources = sources; this.mailService = mailService; this.dkimService = dkimService; this.spfService = spfService; this.dmarcService = dmarcService; this.hostname = hostname; } /** Blocking: call from a worker thread. */ public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted) throws MessagingException, IOException { deliver(raw, mailbox, null, remoteIp, envelopeFrom, trusted); } /** @param folder own folder of the mailbox ("+tag", mailing list), created when missing; null — INBOX */ public void deliver(byte[] raw, String mailbox, String folder, String remoteIp, String envelopeFrom, boolean trusted) throws MessagingException, IOException { MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw)); String senderEmail = envelopeFrom; String senderName = null; if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) { senderEmail = from.getAddress(); senderName = from.getPersonal(); } String subject = mime.getSubject() == null ? "" : mime.getSubject(); Body body = body(mime); boolean spam = false; if (!trusted) { DkimService.Verification dkim = dkimService.verify(raw); String spfSender = envelopeFrom == null || envelopeFrom.isEmpty() ? senderEmail : envelopeFrom; SpfService.SpfResult spf = spfService.checkSpf(spfSender, remoteIp); boolean spfPassed = spf == SpfService.SpfResult.PASS; DmarcService.DmarcResult dmarc = dmarcService.checkDmarc(senderEmail, remoteIp, spfPassed, dkim.passed(), dkim.domain()); spam = dmarcService.shouldMarkAsSpam(dmarc) || spf == SpfService.SpfResult.FAIL || (dkim.signed() && !dkim.passed()); log.info("Incoming {} -> {} from {}: DKIM {}, SPF {}, DMARC {}{}", senderEmail, mailbox, remoteIp, dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : ""); } com.mailserver.domain.Message stored = mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam, addresses(mime, jakarta.mail.Message.RecipientType.TO), addresses(mime, jakarta.mail.Message.RecipientType.CC), folder).block(); if (stored != null) { for (FilePart file : attachments(mime)) { mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes()); } // IMAP serves the message exactly as it arrived sources.put(stored.getId(), raw).block(); } } /** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */ public static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) { try { jakarta.mail.Address[] list = mime.getRecipients(type); if (list == null || list.length == 0) { return null; } List result = new ArrayList<>(); for (jakarta.mail.Address address : list) { result.add(address instanceof InternetAddress internet ? internet.getAddress() : address.toString()); } String joined = String.join(", ", result); return joined.length() > 5000 ? joined.substring(0, 5000) : joined; } catch (MessagingException e) { return null; } } public record FilePart(String name, String contentType, byte[] bytes) { } /** Parts with a file name (attachments and inline images), nested multiparts walked. */ public static List attachments(Part part) throws MessagingException, IOException { List files = new ArrayList<>(); collect(part, files); return files; } private static void collect(Part part, List files) throws MessagingException, IOException { if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) { for (int i = 0; i < multipart.getCount(); i++) { collect(multipart.getBodyPart(i), files); } return; } String name = part.getFileName(); if (name == null && !Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) { return; } String decoded = name == null ? "file" : MimeUtility.decodeText(name); String type = part.getContentType() == null ? "application/octet-stream" : part.getContentType().split(";")[0].trim(); try (InputStream in = part.getInputStream()) { files.add(new FilePart(decoded, type.toLowerCase(java.util.Locale.ROOT), in.readAllBytes())); } } /** Non-delivery report into the sender's inbox — only when the sender is our mailbox. */ @Override public void bounce(OutboundMessage message, String reason) { String text = "Your message to " + message.getRcptTo() + " could not be delivered.\n\n" + "Reason: " + reason + "\n\nAttempts: " + message.getAttempts() + "\n"; try { mailService.receiveMessage("mailer-daemon@" + hostname, "Mail Delivery System", message.getMailFrom(), "Undelivered: message to " + message.getRcptTo(), text, false, false).block(); } catch (IllegalArgumentException notOurs) { log.info("Bounce not stored: sender {} is not a local mailbox", message.getMailFrom()); } } public record Body(String text, boolean html) { } /** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */ public static Body body(Part part) throws MessagingException, IOException { String html = find(part, "text/html"); if (html != null) { return new Body(html, true); } String plain = find(part, "text/plain"); return new Body(plain == null ? "" : plain, false); } private static String find(Part part, String type) throws MessagingException, IOException { if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) { return null; } if (part.isMimeType(type)) { Object content = part.getContent(); if (content instanceof String s) { return s; } if (content instanceof InputStream in) { try (in) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); } } return null; } if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) { for (int i = 0; i < multipart.getCount(); i++) { BodyPart child = multipart.getBodyPart(i); String found = find(child, type); if (found != null) { return found; } } } return null; } }