1
\$\begingroup\$

I have this service that reads emails from a mailbox as they come and I want to extract its contents in a map structure. For now I am interested only in the actual email (no images or attachments).

I started by using an enum when I wanted something simple and then extended the enum to implement an interface when I noticed that I needed some complexity and at the end because the method extracting the content was a few lines long and the enum was getting hard to read I extracted the implementation to their own interface implementations and just assigned an instance of each in the enum.

It doesn't feel wrong, I actually think that this is what Spring looks like (very simply for conceptual purposes) when it creates singletons for autowiring but wanted to get an opinion by the community and if this is a valid approach, is there something I missed or something to consider

This is the interface of the extractor with a default method for something that is common to the implementations

public interface ContentExtractor {
    Map<String, String> extract(Message mimeMessage) throws IOException, MessagingException;

    default String extract(BodyPart bodyPart) {
        try {
            return (String)bodyPart.getContent();
        }
        catch (IOException | MessagingException e) {
            LOGGER.warn("Couldn't cast part to a String. It's either a multipart body part or probably a content type that resolves to a stream.");
            return null;
        }
    }
}

The abstract class for a base. I am actually divided on whether I need this, I don't actually remember why I added it...

@Getter
public abstract class MimeContentExtractor implements ContentExtractor {
    private final String mimeType;
    MimeContentExtractor(String mimeType) {this.mimeType = mimeType;}

    static String getContentType(Part part) {
        try {
            return part.getContentType();
        } catch (MessagingException e) {
            LOGGER.warn("Couldn't get the content type of the part");
            return "N/A";
        }
    }
}

The enum

@Getter
public enum MimeType {
    MULTIPART(new MultipartContentExtractor("multipart/")),
    TEXT(new TextContentExtractor("text/plain")),
    HTML(new TextContentExtractor("text/html"));

    private final MimeContentExtractor contentExtractor;

    MimeType(MimeContentExtractor contentExtractor) {
        this.contentExtractor = contentExtractor;
    }

    public static Optional<MimeContentExtractor> getExtractor(Part part) {
        return determineMimeType(part)
                .map(MimeType::getContentExtractor);
    }

    private static Optional<MimeType> determineMimeType(Part part) {
        return Arrays.stream(values())
                .filter(ct -> ct.matchesContentType(part))
                .findFirst();
    }

    private boolean matchesContentType(Part part) {
        try {
            return part.getContentType().startsWith(getMimeType());
        } catch (MessagingException e) {
            LOGGER.warn("Failed to determine content type of {}", part);
            return false;
        }
    }

    public String getMimeType() {
        return contentExtractor.getMimeType();
    }
}

I have two implementations for content. The text/plain and text/html just override extract and return the content forcing it to be a string (Java mail, this is how it works) and the multipart/mixed is a bit more complex but basically goes through the parts, and uses the enum again to determine what extractor to use. For now it will recognise the text and use the aforementioned implementations.

public class MultipartContentExtractor extends MimeContentExtractor {
    MultipartContentExtractor(String mimeType) {
        super(mimeType);
    }

    @Override
    public Map<String, String> extract(Message mimeMessage) throws IOException, MessagingException {
        Multipart content = (Multipart)mimeMessage.getContent();
        return extract(content);
    }

    private Map<String, String> extract(Multipart content) throws MessagingException {
        int numberOfBodyParts = content.getCount();
        LOGGER.info("The content has {} parts", numberOfBodyParts);

        Map<String, String> bodyPartContents = new HashMap<>();
        IntStream.range(0, numberOfBodyParts)
                .boxed()
                .peek(i -> LOGGER.info("Attempting to get body part {}", i))
                .map(wrapWithOptional(content::getBodyPart))
                .peek(bp -> LOGGER.info("Body part was {} retrieved", bp.isPresent() ? "" : "not"))
                .filter(Optional::isPresent)
                .map(Optional::get)
                .forEach(bodyPart -> {
                    LOGGER.info("Content type of body part is {}", getContentType(bodyPart));
                    MimeType.getExtractor(bodyPart)
                            .ifPresent(ce -> bodyPartContents.put(ce.getMimeType(), ce.extract(bodyPart)));
                });
        return unmodifiableMap(bodyPartContents);
    }
}

@Getter
public class TextContentExtractor extends MimeContentExtractor {
    TextContentExtractor(String mimeType) {
        super(mimeType);
    }

    @Override
    public Map<String, String> extract(Message mimeMessage) throws IOException, MessagingException {
        return singletonMap(getMimeType(), (String)mimeMessage.getContent());
    }
}

Finally this is how it is used

@Component
public class BackofficeMailProcessor implements Processor {
    ...
    @Override
    public void process(Exchange exchange) {
        MailMessage mailMessage = (MailMessage)exchange.getMessage();

        Map<String, String> contents = getContents(mailMessage);
        if (contents.isEmpty()) {
            throw new BackofficeMailProcessorException("Couldn't get any of the contents of the email. Stopped processing!");
        }
        ...
    }

    private Map<String, String> getContents(MailMessage mailMessage) {
        final Message mailMessageBody = mailMessage.getMessage();
        Optional<MimeContentExtractor> extractor = MimeType.getExtractor(mailMessageBody);
        if (extractor.isPresent()) {
            try {
                return extractor.get().extract(mailMessageBody);
            }
            catch (IOException | MessagingException e) {
                LOGGER.error("Failed to extract contents of email", e);
            }
        }

        return emptyMap();
    }
    ...
}

The fact that is an enum doesn't make it optimal for unit testing. This is part of a larger project and I did something similar for extracting reply for different kind of email clients (yahoo, gmail, etc) but there I also made a factory where I used the enum in the supplier (not a static one) and wired the factory where I would use the Enum in the above example.

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

I'm absolutely sure there is discussion about this problem already, but I just can't remember the correct search keywords. Anyway, the main problem is that it's closed for extension. The list of supported MIME types are hard coded to the enumeration and can not be added by the library user. The difficulty of unit testing is a result from this closedness.

\$\endgroup\$
1
  • \$\begingroup\$ Fair point, I am still struggling a bit with the Open-Closed principle. In my case though this is not for a library but for a very specific microservice to be used internally... Is it possible to leave the implementations as is and use Spring to load them as a list of the interface and every time I could iterate through them and use the getter for the MimeType for each implementation to decide which to use. Would that be better? Then a user of the library could override the abstract and just pass their own MimeType... \$\endgroup\$ Commented Jul 1, 2019 at 7:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.