So you want to write a KMail plugin?

Created on 2024-06-16 at 19:27

Recently, I've been moving away from macOS to Linux, and have settled on using KDE Plasma as my desktop environment. For the most part I've been comfortable with the change, but it's always the small things that get me. For example, the Mail app built into macOS provides an "Unsubscribe" button for emails. It looks like this:

Banner saying "This message is from a mailing list." A button next to the text says "Unsubscribe."

You click "Unsubscribe," and you're unsubscribed. No pleading taco emoji, no line of check boxes, just a plainly-labeled button at the top of the email.

Apparently this is also supported in some webmail clients, but I'm not interested in accessing my email that way.[1] Unfortunately, I haven't found an X11 or Wayland email client that supports this sort of functionality, so I decided to implement it myself. And anyway, I'm trying out Kontact for my mail at the moment, which supports plugins. So why not use this as an opportunity to build one?

What is Kontact?

Kontact is an all-in-one Personal Information Manager (PIM) developed for KDE. The actual "Kontact" program is more like a fancy frontend: Email is handled via KMail, KOrganizer provides to-do lists and calendars, and KAddressBook... well, these names aren't the most original. All of these programs can run independently, or be embedded into Kontact as KParts and using the KontactInterface::Plugin interface. These are called "Kontact plugins," and are not what we want to build.

However, there is another set of plugins. If you go to "Configure KMail...", you can see them:

"Configure KMail" dialog, on the Plugins tab. A list of various plugins are displayed.

These are split up into different categories: Akonadi Agents, Composer Plugins, Editor Plugins, and a category named "Message Viewer." Since we'd need to act on the message viewer, we'll want to write one of those. But there's no "install plugin" button. How do we start building one? If it's not a Kontact plugin, is it an Akonadi plugin? Or a secret third thing?

Akonadi and Messagelib

To quote KDE:

The Akonadi framework is responsible for providing applications with a centralized database to store, index and retrieve the user's personal information. This includes the user's emails, contacts, calendars, events, journals, alarms, notes, etc.

In essence, this is the backend of Kontact (Akregator excluded). So we won't be building a plugin for Akonadi, but we will be interacting with it shortly.

The plugins we see in the Configure window come from the PIM/kdepim-addons repository. We'll use the "Create Event" plugin as our reference, and we can find it in plugins/messageviewerplugins/createeventplugin. From there, we can find the interface being used:

namespace MessageViewer
{
class ViewerPluginCreateevent : public MessageViewer::ViewerPlugin
{
    Q_OBJECT
public:
    explicit ViewerPluginCreateevent(QObject *parent = nullptr, const QList<QVariant> & = QList<QVariant>());
    ViewerPluginInterface *createView(QWidget *parent, KActionCollection *ac) override;
    [[nodiscard]] QString viewerPluginName() const override;
};
}

MessageViewer isn't a direct part of KMail or Kontact. It's part of Messagelib, which contains various widgets used for displaying and composing emails. These widgets are then used by KMail.

Okay great, we finally have the interface and library we need to start building a plugin! Now to pull up the docs for MessageViewer::ViewerPlugin, and

A screenshot of API documentation. Method and class names are listed, but no context or descriptions are given.

oh

Working Backwards

Normally, I'd start by building code and set up the build system as I go. At least for smaller projects, it can be easier to hit the ground running. But since we have no idea what we're working with here, I'm going to set up the bare minimum to get a working plugin.

Due to the way KDE Frameworks are, we're going to want to use CMake for this. The good news is we don't have to think too hard about it, thanks to KCoreAddons and KDE's library of CMake Modules, known as, uh, Extra CMake Modules.

# I've been testing with KF6, but maybe this will work with KF5
set(KF_MIN_VERSION "6.0.0")

# Extra CMake Modules (ECM) setup
find_package(ECM ${KF_MIN_VERSION} CONFIG REQUIRED)
set(CMAKE_MODULE_PATH ${ECM_MODULE_DIR} ${ECM_KDE_MODULE_DIR})
include(ECMQtDeclareLoggingCategory)
include(KDEInstallDirs)
include(KDECMakeSettings)

set(kmail_unsubscribe_SRCS
	# C++ sources go here...
	unsubscribeplugin.cpp
	unsubscribeplugin.h
)

ecm_qt_declare_logging_category(
	kmail_unsubscribe_SRCS
	# What the header will be
	HEADER "unsubscribe_debug.h"
	# The object name
	IDENTIFIER "UnsubscribePlugin"
	# How to refer to it externally, e.g. by
	# QT_LOGGING_RULES
	CATEGORY_NAME "xyz.datagirl.kpim.unsubscribe"
	DESCRIPTION "Unsubscribe Plugin"
	DEFAULT_SEVERITY Info
	EXPORT
)

# Build a .so, not .a
set(BUILD_SHARED_LIBS ON)

# Let the KCoreAddons macro make our target
kcoreaddons_add_plugin(kmail_unsubscribe
	SOURCES ${kmail_unsubscribe_SRCS}
	# Used when installed
	INSTALL_NAMESPACE pim6/messageviewer/viewerplugin)
# Now we can link libraries, set properties, etc
target_link_libraries(kmail_unsubscribe
	KPim6::PimCommon
	#...
)

Now we have to define the metadata in a JSON file. In my case, it's kmail_unsubscribeplugin.json:

{
	"KPlugin": {
		"Description": "Adds an Unsubscribe button to messages",
		"EnabledByDefault": true,
		"Name": "Unsubscribe",
		"Version": "2.0"
	}
}

Then, integrate it with your plugin class. For example, I have in unsubscribeplugin.cpp:

// UnsubscribePlugin is defined in this header
#include "unsubscribeplugin.h"

#include <KPluginFactory>

using namespace MessageViewer;
K_PLUGIN_CLASS_WITH_JSON(UnsubscribePlugin, "kmail_unsubscribeplugin.json")

Finally, we're getting to code! First, we'll build out the scaffolding for UnsubscribePlugin in the header:

// Requires KPim6MessageViewer
#include <MessageViewer/ViewerPlugin>
#include <QVariant>

namespace MessageViewer {
	class UnsubscribePlugin
		: public MessageViewer::ViewerPlugin
	{
		Q_OBJECT
	public:
		explicit UnsubscribePlugin(QObject *parent = nullptr,
			const QList<QVariant> & = QList<QVariant>());

		[[nodiscard]] ViewerPluginInterface *createView(QWidget *parent,
			KActionCollection *ac) override;

		[[nodiscard]] QString viewerPluginName() const override
		{
			return QStringLiteral("oneclick-unsubscribe");
		};
	};
}

And in the source file:

// Pass up parent, ignore QList
UnsubscribePlugin::UnsubscribePlugin(
	QObject *parent,
	const QList<QVariant> &
) : MessageViewer::ViewerPlugin(parent)
{
}

// Create a new plugin interface when asked
ViewerPluginInterface *
UnsubscribePlugin::createView(
	QWidget *parent,
	KActionCollection &ac
)
{
	MessageViewer::ViewerPluginInterface *view =
		new UnsubscribePluginInterface(ac, parent);
	return view;
}

// Meta Object Code (MOC) with the plugin definition
#include "unsubscribeplugin.moc"
// MOC for the CPP file
#include "moc_unsubscribeplugin.cpp"

The Plugin Interface

Up until this point, we've been just getting the plugin structure setup. The real core of the plugin is the plugin interface class, defined in our case by ViewerPluginInterface. While its API documentation is lacking, it'll help us define our subclass:

#include <MessageViewer/ViewerPluginInterface>

namespace MessageViewer
{
	class UnsubscribePluginInterface
		: public MessageViewer::ViewerPluginInterface
	{
		Q_OBJECT
	public:
		explicit UnsubscribePluginInterface(
			QWidgetParent *parent = nullptr,
			KActionCollection *ac);
		~UnsubscribePluginInterface() override;

		// In our case, we'll be returning mActions (see below)
		[[nodiscard]] QList<QAction *> actions() const override;

		// We'll get to these four shortly
		void updateAction(const Akonadi::Item &item) override;
		void setMessageItem(const Akonadi::Item &item) override;
		void execute() override;
		void closePlugin() override;

		// This defines what your plugin supports. More on that
		// in a moment.
		[[nodiscard]] ViewerPluginInterface::SpecificFeatureTypes
			featureTypes() const override
		{
			return ViewerPluginInterface::NeedMessage;
		}
	private:
		// What we'll be returning in actions()
		QList<QAction *> mActions;
	};
}

For those who looked at the API docs, you might have noticed I'm skipping quite a few methods. The parent class, ViewerPluginInterface, defines all those methods as no-ops for us, so we don't need to implement what we don't care about.

The featureTypes() method defines the places it makes sense for your plugin to appear. While I haven't been able to figure out the specifics, the general idea seems to be:

  • Define ViewerPluginInterface::NeedMessage when you're working with the whole message (ex., toolbar items on the message view)
  • Define ViewerPluginInterface::NeedText when you'll be dealing with the currently-selected text in a message
  • Define ViewerPluginInterface::NeedUrl when you'll be interfacing with a clicked URL

These can be combined with bitwise OR, if you need multiple. You can also just define ViewerPluginInterface::None if you have no need for any of those.

Okay, enough with the class definitions. Let's put together the constructor:

using namespace MessageViewer;

UnsubscribePluginInterface::UnsubscribePluginInterface(
	QWidget *parent,
	KActionCollection *ac
) : ViewerPluginInterface(parent)
{
	if (ac)
	{
		// Create an action...
		auto action = new QAction(this);
		action->setIcon(QIcon::fromTheme(QStringLiteral("news-unsubscribe")));
		action->setIconText(QStringLiteral("Unsubscribe"));
		action->setWhatsThis(QStringLiteral("Unsubscribe from the mailing list"));
		// ... and add it to the collection
		ac->addAction(QStringLiteral("oneclick_unsubscribe"), action);
		// Lastly, connect the triggered signal to a slot defined
		// upstream
		connect(
			action,
			&QAction::triggered,
			this,
			&UnsubscribePluginInterface::slotActivatePlugin
		);
		// Finally, add to our internal actions list
		mActions.append(action);
	}
}

This part is pretty straightforward: create a QAction, add it to the application's collection, and hook up the signal that occurs when the action is activated (e.g. when clicked, or a relevant keyboard shortcut is pressed).

That should be the last of our scaffolding. Finally, onto the interesting part!

The Interesting Part

Our plugin's journey truly starts when the user selects an email. When this happens, the Message Viewer calls our plugin interface's updateAction(Akonadi::Item &item) method.

While Akonadi::Items are general objects that can contain a number of things, we'll only receive ones with a shared pointer to a KMime::Message. Switching folders appears to cause an updateAction call with an Akonadi::Item that has a null payload, so you may want to guard against that:

if (item.hasPayload<KMime::Message::Ptr>())
{
	mMessage = item.payload<KMime::Message::Ptr>();
	if (mMessage == nullptr)
	{
		// Couldn't get the KMime message, bail
		return;	
	}

	// Now we can work with mMessage!
}

As might be evident by the method name, updateAction is to update our action for the email we're on. In the case of our unsubscribe plugin, we may want to disable the action if we determine the user can't automatically unsubscribe from this email.

Once our action is triggered, the following bit of code in Messagelib is run:

void ViewerPrivate::slotActivatePlugin(ViewerPluginInterface *interface)
{
    interface->setMessage(mMessage);
    interface->setMessageItem(mMessageItem);
    interface->setUrl(mClickedUrl);
    interface->setCurrentCollection(mMessageItem.parentCollection());
    const QString text = mViewer->selectedText();
    if (!text.isEmpty()) {
        interface->setText(text);
    }
    interface->execute();
}

We won't need to implement all of these methods for our plugin, but since we're here anyway, here's my understanding of the process:

  1. setMessage(mMessage) provides us with the actual message pointer.
  2. setMessageItem(mMessageItem) gives us the Akonadi::Item corresponding to the message.
  3. If a URL is selected, setUrl(mClickedUrl) would give us the URL that was clicked.
  4. setCurrentCollection(...) gives us the parent Akonadi::Collection for our given message. This would most often be the email's folder.
  5. If there's selected text, setText(text) passes that to us.
  6. Finally, execute() is our cue to start doing our thing!

There's one last important method: closePlugin(). This is called when the current email is closed, to tell plugins to flush the current state. This would of course include closing the window, but more importantly includes every time the user changes the active email. In such a case, updateAction is called almost immediately, and the cycle repeats.

Aside: One-Click Unsubscribe

Feels a bit silly to call this an aside, but the point of this post is to share my findings about this plugin API, not the thing I wanted to implement. anyway

One-Click Unsubscribe is an actual standard, RFC 8058. As far as we're concerned, the mail sender's requirements boil down to:

  • The email MUST have a List-Unsubscribe header with only one HTTPS URI (any number of other URIs is fine)
  • The email MUST also have a List-Unsubscribe-Post header, which MUST have the value of List-Unsubscribe=One-Click
  • The email MUST be signed with DKIM, and the List-Unsubscribe and List-Unsubscribe-Post headers MUST be in the list of signed headers

For our end as the receiver, we just have to send a POST request to the URI given by the headers, with a form field named List-Unsubscribe with the value One-Click. Easy!

Bonus Aside: DKIM

Okay, not so easy. That last bullet point mentions the requirement for DomainKeys Identified Mail (DKIM), a standard for authenticating email by cryptographically signing a defined set of headers and the body of an email. KMail supports DKIM, so we should be able to use whatever it uses, right?

Mostly! There's MessageViewer::DKIMManager which is used to check DKIM signatures. It also provides a singleton-like ::self() method, so one might think we could just do something like:

connect(DKIMManager::self(),
	   &DKIMManager::result,
	   this,
	   &SomePluginInterface::slotDkimResult);
DKIMManager::self()->checkDKim(mMessageItem);

Which does work, but we're not the only one connected to this instance. Doing this leads to an amusing case where, when checkDKim() is run, this little status bar item:

DKIM: valid (signed by kde.org)

Immediately changes to:

DKIM: invalid

This is because our message item might not contain the body, and since the body is also signed, the check fails. My solution is to just build my own DKIMManager instance. This is probably more "correct," but I'm curious what the use case for using DKIMManager::self() would be.

And all the Rest

And now you have at least a vague idea of how to build a plugin for a specific component of a specific part of Kontact!

"But what if I want to build a really cool plugin for (email composing/KAddressBook/Akonadi)?" I hear you ask. Well, dear reader, I have no idea. It looks like most other plugins are derived from PimCommon::GenericPluginInterface, which is independent of MessageViewer::ViewerPluginInterface.

My recommendation would be to look at existing plugins that do something remotely adjacent to what you want to do and look at the interfaces it implements. All of the built-in plugins come from PIM/kdepim-addons, so there's a good chance the repository is in there.

If the KDE API docs are failing you and web searches don't pull anything up, you'll want to reverse engineer those interfaces. Try to make a barebones plugin derived from your "sample," using ecm_qt_declare_logging_category to create a new logging category and a bunch of qCDebug(YourCategory) calls to trace the behavior of those methods. If all else fails, search the KDE PIM libraries' source code for where the interface would call your plugin!

yes this was the most efficient way to get the functionality i wanted, why do you ask :)

If you're interested in the project that kickstarted all of this, I have it available both on my Git server and on GitHub.


  1. I'm very particular about what goes on in my web browser! I've got 50 tabs of the same search result page, five videos, and a hundred or so manual pages for projects I've since abandoned. The important stuff!