4

I am attempting to run my own custom in-memory James assembly inside of a Quarkus web application. I've been following along with the example here and gotten the server to run.

One of the issues I'm running into is that according to the docs I need to include custom hooks inside of a Jar file in a /conf/lib directory however I wish to use classes directly from inside the same project James is embedded in. Is there a way to modify the classloader so that I can use hooks that exist in the same jar where James is going to exist?

Edit: Adding handlerchain config. Sorry VonC I might have accidentally submitted this as an edit to your answer. It's been a day!

        <handlerchain>
            <handler class="org.apache.james.smtpserver.fastfail.ValidRcptHandler"/>
            <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/>
            <handler class="tech.gmork.oxprox.control.hooks.CustomAuthHook"/>
            <handler class="tech.gmork.oxprox.control.hooks.CustomMessageHook"/>
        </handlerchain>
TheFunk
  • 981
  • 11
  • 39

1 Answers1

2

Is there a way to modify the classloader so that I can use hooks that exist in the same jar where James is going to exist?

"modify the classloader"? ... not without re-compiling the apache/james-project itself, I would presume.

Apache James likely uses a dedicated component for classloading, which could be based on Java's URLClassLoader or a similar class.
I can see org.apache.james.utils.ExtendedClassLoader

You would need to modify it to check the current application classpath (where your Quarkus app and embedded James reside) in addition to or instead of the conf/lib directory.
Then, after making the changes, you will need to recompile James and integrate the custom version with your Quarkus application.

The XML configurations you have for server implementations will not need to change because of these modifications. The XML file merely instructs James on what class to instantiate and possibly initialize. By modifying the classloading behavior, you are just changing where James looks for these classes. The XML stays the same because it references classes by their fully qualified names, not their locations.

I initially propose an updated code for ExtendedClassLoader, but, as noted by Holger in the comments:

A URLClassLoader follows the standard parent delegation model. Since getClass().getClassLoader() is passed to the constructor as the parent loader, it will be searched for classes, even before the URLs passed to the constructor are searched.
Merging currentClasspath with extensionUrls has no effect.

package org.apache.james.utils;

// ... [other imports]

public class ExtendedClassLoader {

    private static final Logger LOGGER = LoggerFactory.getLogger(ExtendedClassLoader.class);

    public static final String EXTENSIONS_JARS_FOLDER_NAME = "extensions-jars/";

    private final URLClassLoader urlClassLoader;

    @Inject
    public ExtendedClassLoader(FileSystem fileSystem) {
        URL[] extensionUrls = retrieveExtensionsUrls(fileSystem);
        URL[] currentClasspath = ((URLClassLoader) getClass().getClassLoader()).getURLs();

        URL[] combinedUrls = Stream.concat(
            Stream.of(extensionUrls),
            Stream.of(currentClasspath)
        ).distinct().toArray(URL[]::new);

        this.urlClassLoader = new URLClassLoader(combinedUrls, getClass().getClassLoader());
    }

    private URL[] retrieveExtensionsUrls(FileSystem fileSystem) {
        try {
            File file = fileSystem.getFile("file://" + EXTENSIONS_JARS_FOLDER_NAME);
            return recursiveExpand(file)
                .toArray(URL[]::new);
        } catch (IOException e) {
            LOGGER.info("No " + EXTENSIONS_JARS_FOLDER_NAME + " folder.");
            return new URL[]{};
        }
    }

    private Stream<URL> recursiveExpand(File file) {
        return StreamUtils.ofNullable(file.listFiles())
            .flatMap(Throwing.function(this::expandFile).sneakyThrow());
    }

    private Stream<URL> expandFile(File file) throws MalformedURLException {
        if (file.isDirectory()) {
            return recursiveExpand(file);
        }
        LOGGER.info("Loading custom classpath resource {}", file.getAbsolutePath());
        return Stream.of(file.toURI().toURL());
    }

    @SuppressWarnings("unchecked")
    public <T> Class<T> locateClass(FullyQualifiedClassName className) throws ClassNotFoundException {
        return (Class<T>) urlClassLoader.loadClass(className.getName());
    }
}

In the constructor ExtendedClassLoader(FileSystem fileSystem), you can retrieve the URLs from the extensions-jars/ folder (just like the original code). And the URLs from the current application's classpath. The combined URLs are ensured to be unique to avoid duplication.
These combined URLs are then fed into the URLClassLoader so that, when James tries to load a class, it will look in both the provided extension URLs and the current application's classpath.

By making these changes, James will first look for hooks in the extensions-jars/ folder (if provided) and then in the current application's classpath. That means if you have packaged James and your custom hooks into the same jar (or same classpath), it should be able to find and load them without issues.

See also "URLClassLoader can't find class thats in different directory".

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
  • Thank you! This gives me a starting point. I will look into extending their classloader and see if there's a way to tell the James server to use my custom class instead. The only hard part is I can't find anywhere in the documentation that would indicate this as supported out of the box. :( – TheFunk Aug 22 '23 at 23:47
  • I think you're right that the only solution would be to compile a custom version of James with the modified loader. It doesn't look like there's any simple way to swap in my own implementation. That's frustrating. I was so close to having the perfect little embedded mail server application. – TheFunk Aug 22 '23 at 23:53
  • @TheFunk I understand your frustration. It might be possible to use reflection to access and modify the `ExtendedClassLoader` instance within James after it has been instantiated. That is a very hacky approach and is likely to be fragile, but in certain cases, it might be an option. – VonC Aug 23 '23 at 09:27
  • @TheFunk Or, if you can isolate your custom hooks into a separate module within your project, you might be able to package that module as a separate jar and place it in the expected `extensions-jars/` directory. You could still depend on that jar within your main project, so the classes would be available both to James and to your application code. – VonC Aug 23 '23 at 09:27
  • 1
    A `URLClassLoader` follows the standard parent delegation model. Since `getClass().getClassLoader()` is passed to the constructor as the parent loader, it will be searched for classes, even before the URLs passed to the constructor are searched. Merging `currentClasspath` with `extensionUrls` has no effect. – Holger Aug 24 '23 at 19:22
  • @Holger I see... would a potential workaround be to build the custom hooks as a separate jar and place that jar in the `extensions-jars/` directory as per James's expected configuration? You could then also include that jar as a dependency in your Maven project, ensuring that the classes are available both to James (through the `ExtendedClassLoader`) and to the rest of your application code. – VonC Aug 24 '23 at 19:38
  • 1
    I’m not sure what problem this approach was supposed to solve. As said, a class loader set up this way *will* find the classes of the parent loader. So while the linked documentation says that the intended deployment of custom hooks are `jar` files inside the `conf/lib` directory, it doesn’t mean that custom hooks implemented in the parent loader wouldn’t work (If they don’t, we need more information about this environment). – Holger Aug 24 '23 at 19:48
  • @Holger OK. I will wait for the OP's feedback, then I realize I did mention `URLClassLoader` in a 2008(!) answer "[How do you change the CLASSPATH within Java?](https://stackoverflow.com/a/252967/6309)". – VonC Aug 24 '23 at 19:55
  • @Holger I get a class not found error when referencing classes in the XML config from the parent project, however the default James hooks work as expected. I realized my working directory for the server is configured as my resources folder. I'm wondering if maybe that's wrong. The project itself is a typical maven project. – TheFunk Aug 24 '23 at 22:44
  • @VonC I could add the hooks into their own JAR but that would require a bit of doing. If I can't get the James server to find my classes that might be the best option. – TheFunk Aug 24 '23 at 22:49
  • @TheFunk OK, let me know how it goes for you. – VonC Aug 25 '23 at 09:37