From 0765a9801bfe3d06c4d0defc32b8e44523632d4b Mon Sep 17 00:00:00 2001 From: snow flurry Date: Sun, 16 Jun 2024 15:09:03 -0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + CMakeLists.txt | 71 ++++++++++++ build-msgs.sh | 39 +++++++ kmail_unsubscribeplugin.json | 8 ++ oneclickunsubscribejob.cpp | 68 ++++++++++++ oneclickunsubscribejob.h | 49 +++++++++ po/kmail_unsubscribe.pot | 100 +++++++++++++++++ unsubscribemanager.cpp | 190 +++++++++++++++++++++++++++++++++ unsubscribemanager.h | 127 ++++++++++++++++++++++ unsubscribeplugin.cpp | 21 ++++ unsubscribeplugin.h | 23 ++++ unsubscribeplugininterface.cpp | 162 ++++++++++++++++++++++++++++ unsubscribeplugininterface.h | 39 +++++++ 13 files changed, 899 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100755 build-msgs.sh create mode 100644 kmail_unsubscribeplugin.json create mode 100644 oneclickunsubscribejob.cpp create mode 100644 oneclickunsubscribejob.h create mode 100644 po/kmail_unsubscribe.pot create mode 100644 unsubscribemanager.cpp create mode 100644 unsubscribemanager.h create mode 100644 unsubscribeplugin.cpp create mode 100644 unsubscribeplugin.h create mode 100644 unsubscribeplugininterface.cpp create mode 100644 unsubscribeplugininterface.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9703f14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode/ +build \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4dcf7b7 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,71 @@ +cmake_minimum_required(VERSION 3.16 FATAL_ERROR) + +project(kmail-unsubscribe VERSION "1.0.0") + +set(KF_MIN_VERSION "6.0.0") +set(QT_REQUIRED_VERSION "6.6.0") + +# Extra CMake Modules +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) + +find_package(Qt6 ${QT_REQUIRED_VERSION} CONFIG REQUIRED Network Widgets) + +find_package(KF6Config ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KF6GuiAddons ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KF6XmlGui ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KF6Parts ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KF6KIO ${KF_MIN_VERSION} CONFIG REQUIRED) + + +find_package(KPim6MailCommon ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KPim6MessageCore ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KPim6MessageViewer ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KPim6Libkdepim ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KPim6PimCommonAkonadi ${KF_MIN_VERSION} CONFIG REQUIRED) +find_package(KF6I18n ${KF_MIN_VERSION} NO_MODULE) +ki18n_install(po) + +set(kmail_unsubscribe_SRCS + unsubscribemanager.cpp + unsubscribemanager.h + unsubscribeplugin.cpp + unsubscribeplugin.h + unsubscribeplugininterface.cpp + unsubscribeplugininterface.h + oneclickunsubscribejob.cpp + oneclickunsubscribejob.h +) + +ecm_qt_declare_logging_category( + kmail_unsubscribe_SRCS + HEADER "unsubscribe_debug.h" + IDENTIFIER "UnsubscribePlugin" + CATEGORY_NAME "xyz.datagirl.kpim.unsubscribe" + DESCRIPTION "Unsubscribe Plugin" + DEFAULT_SEVERITY Info + EXPORT +) + +set(BUILD_SHARED_LIBS ON) + +kcoreaddons_add_plugin(kmail_unsubscribe + SOURCES ${kmail_unsubscribe_SRCS} + INSTALL_NAMESPACE pim6/messageviewer/viewerplugin) +target_link_libraries(kmail_unsubscribe + KPim6::PimCommon + KPim6::Libkdepim + KPim6::PimCommonAkonadi + KPim6::MessageViewer + KF6::XmlGui + KF6::KIOCore + KF6::KIOGui + KF6::KIOWidgets + KF6::I18n) +set_target_properties(kmail_unsubscribe PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) \ No newline at end of file diff --git a/build-msgs.sh b/build-msgs.sh new file mode 100755 index 0000000..d2b33a4 --- /dev/null +++ b/build-msgs.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +APPNAME="kmail_unsubscribe" + +DEFAULT_XGETTEXT="$(command -v xgettext)" +XGETTEXT_PROGRAM="${XGETTEXT:-${DEFAULT_XGETTEXT}}" +if [ -z "$XGETTEXT_PROGRAM" ] ; then + echo "error: Couldn't find xgettext. Set \$XGETTEXT to its path, or make sure you have gettext installed." >&2 + exit 1 +fi + +# Pulled from https://invent.kde.org/sysadmin/l10n-scripty/-/blob/master/extract-messages.sh +do_xgettext() { + $XGETTEXT_PROGRAM --copyright-holder="snow flurry" \ + --package-name=$APPNAME \ + --msgid-bugs-address=https://git.2ki.xyz/snow/kmail_unsubscribe \ + --from-code=UTF-8 \ + -C --kde \ + -ci18n \ + -ki18n:1 -ki18nc:1c,2 -ki18np:1,2 -ki18ncp:1c,2,3 \ + -ki18nd:2 -ki18ndc:2c,3 -ki18ndp:2,3 -ki18ndcp:2c,3,4 \ + -kki18n:1 -kki18nc:1c,2 -kki18np:1,2 -kki18ncp:1c,2,3 \ + -kki18nd:2 -kki18ndc:2c,3 -kki18ndp:2,3 -kki18ndcp:2c,3,4 \ + -kxi18n:1 -kxi18nc:1c,2 -kxi18np:1,2 -kxi18ncp:1c,2,3 \ + -kxi18nd:2 -kxi18ndc:2c,3 -kxi18ndp:2,3 -kxi18ndcp:2c,3,4 \ + -kkxi18n:1 -kkxi18nc:1c,2 -kkxi18np:1,2 -kkxi18ncp:1c,2,3 \ + -kkxi18nd:2 -kkxi18ndc:2c,3 -kkxi18ndp:2,3 -kkxi18ndcp:2c,3,4 \ + -kkli18n:1 -kkli18nc:1c,2 -kkli18np:1,2 -kkli18ncp:1c,2,3 \ + -kklxi18n:1 -kklxi18nc:1c,2 -kklxi18np:1,2 -kklxi18ncp:1c,2,3 \ + -kI18N_NOOP:1 -kI18NC_NOOP:1c,2 \ + -kI18N_NOOP2:1c,2 -kI18N_NOOP2_NOSTRIP:1c,2 \ + -ktr2i18n:1 -ktr2xi18n:1 \ + "$@" + +} + +SRC_ROOT="$(dirname "${BASH_SOURCE[0]}")" + +do_xgettext `find "${SRC_ROOT}" \( -name \*.cpp -o -name \*.h -o -name \*.qml \)` -o "${SRC_ROOT}/po/${APPNAME}.pot" \ No newline at end of file diff --git a/kmail_unsubscribeplugin.json b/kmail_unsubscribeplugin.json new file mode 100644 index 0000000..7d3495c --- /dev/null +++ b/kmail_unsubscribeplugin.json @@ -0,0 +1,8 @@ +{ + "KPlugin": { + "Description": "Adds RFC 8058 One-Click Unsubscribe to KMail.", + "EnabledByDefault": true, + "Name": "Unsubscribe", + "Version": "2.0" + } +} \ No newline at end of file diff --git a/oneclickunsubscribejob.cpp b/oneclickunsubscribejob.cpp new file mode 100644 index 0000000..e903730 --- /dev/null +++ b/oneclickunsubscribejob.cpp @@ -0,0 +1,68 @@ +#include "oneclickunsubscribejob.h" + +#include +#include +#include "unsubscribe_debug.h" + +using namespace MessageViewer; + +OneClickUnsubscribeJob::OneClickUnsubscribeJob(QUrl &oneClickUrl, UnsubscribeManager *parent) + : QObject(parent), + mNetworkAccessManager(new QNetworkAccessManager(this)) +{ + mNetworkAccessManager->setStrictTransportSecurityEnabled(true); + mNetworkAccessManager->enableStrictTransportSecurityStore(true); + + connect(mNetworkAccessManager, &QNetworkAccessManager::finished, this, &OneClickUnsubscribeJob::slotFinished); + connect(mNetworkAccessManager, &QNetworkAccessManager::sslErrors, this, &OneClickUnsubscribeJob::slotSslErrors); + + mUrl = oneClickUrl; +} + +void OneClickUnsubscribeJob::start() +{ + QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType, this); + QHttpPart mainPart; + // Per RFC 8058, only "List-Unsubscribe=One-Click" is allowed. To avoid + // parsing issues, we simply won't parse anything + mainPart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"List-Unsubscribe\"")); + mainPart.setBody("One-Click"); + multiPart->append(mainPart); + QNetworkRequest request(mUrl); + + qCDebug(UnsubscribePlugin) << "Sending one-click unsubscribe request to" << mUrl; + + mReply = mNetworkAccessManager->post(request, multiPart); + connect(mReply, &QNetworkReply::errorOccurred, this, &OneClickUnsubscribeJob::slotError); +} + +void OneClickUnsubscribeJob::slotSslErrors(QNetworkReply *reply, const QList &error) +{ + // TODO: allow override somehow + qCDebug(UnsubscribePlugin) << "Got" << error.count() << "SSL error(s)"; + UnsubscribeManager::Result sslErrResult = { + .Type = UnsubscribeManager::Result::SslError, + .ErrorString = error.first().errorString(), + }; + Q_EMIT result(sslErrResult); +} + +void OneClickUnsubscribeJob::slotFinished(QNetworkReply *reply) +{ + qCDebug(UnsubscribePlugin) << "Successful response from" << mUrl << "with result" << reply->errorString(); + UnsubscribeManager::Result successResult = { + .Type = UnsubscribeManager::Result::None, + }; + Q_EMIT result(successResult); +} + +void OneClickUnsubscribeJob::slotError(QNetworkReply::NetworkError error) +{ + UnsubscribeManager::Result errorResult = { + .Type = UnsubscribeManager::Result::NetworkError, + .ErrorString = mReply->errorString(), + }; + Q_EMIT result(errorResult); +} + +#include "moc_oneclickunsubscribejob.cpp" \ No newline at end of file diff --git a/oneclickunsubscribejob.h b/oneclickunsubscribejob.h new file mode 100644 index 0000000..e3c136c --- /dev/null +++ b/oneclickunsubscribejob.h @@ -0,0 +1,49 @@ +#ifndef _ONECLICKUNSUBSCRIBEJOB_H_ +#define _ONECLICKUNSUBSCRIBEJOB_H_ + +#include +#include +#include + +#include "unsubscribemanager.h" + +namespace MessageViewer +{ + class OneClickUnsubscribeJob : public QObject + { + Q_OBJECT + public: + explicit OneClickUnsubscribeJob(QUrl &oneClickUrl, UnsubscribeManager *parent); + ~OneClickUnsubscribeJob() = default; + + /** + * @brief Starts the unsubscribe job. + * + */ + void start(); + + // Slots are used for connection to mNetworkAccessManager + public slots: + void slotFinished(QNetworkReply *reply); + void slotSslErrors(QNetworkReply *reply, const QList &errors); + void slotError(QNetworkReply::NetworkError error); + + signals: + /** + * @brief Triggered on job completion/failure. + * + * @param success Whether the job was successful. If true, sslError and error should be ignored. + * @param sslError If unsuccessful, whether one or more SSL errors occurred. + * @param error If unsuccessful, the error code returned by the QNetworkRequest. + */ + void result(const UnsubscribeManager::Result &data); + + private: + QNetworkAccessManager *const mNetworkAccessManager; + QUrl mUrl; + bool sentResult = false; + QNetworkReply *mReply = nullptr; + }; +} + +#endif /* !_ONECLICKUNSUBSCRIBEJOB_H_ */ \ No newline at end of file diff --git a/po/kmail_unsubscribe.pot b/po/kmail_unsubscribe.pot new file mode 100644 index 0000000..48c5fe4 --- /dev/null +++ b/po/kmail_unsubscribe.pot @@ -0,0 +1,100 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR snow flurry +# This file is distributed under the same license as the kmail_unsubscribe package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: kmail_unsubscribe\n" +"Report-Msgid-Bugs-To: https://git.2ki.xyz/snow/kmail_unsubscribe\n" +"POT-Creation-Date: 2024-06-13 00:32-0700\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: unsubscribemanager.cpp:178 +#, kde-format +msgid "Unable to send unsubscribe request: %1" +msgstr "" + +#: unsubscribemanager.cpp:181 +#, kde-format +msgid "Got one or more SSL errors: %1" +msgstr "" + +#: unsubscribemanager.cpp:184 +#, kde-format +msgid "Plugin hit an unreachable point" +msgstr "" + +#: unsubscribeplugininterface.cpp:59 +#, kde-format +msgid "Network not available" +msgstr "" + +#: unsubscribeplugininterface.cpp:60 +#, kde-format +msgid "Please go back online to unsubscribe from this list." +msgstr "" + +#: unsubscribeplugininterface.cpp:72 +#, kde-format +msgid "Can't Unsubscribe" +msgstr "" + +#: unsubscribeplugininterface.cpp:73 +#, kde-format +msgid "This email doesn't advertise a way to unsubscribe." +msgstr "" + +#: unsubscribeplugininterface.cpp:93 +#, kde-format +msgid "The digital signature of this email couldn't be validated." +msgstr "" + +#: unsubscribeplugininterface.cpp:94 +#, kde-format +msgid "Do you still want to unsubscribe?" +msgstr "" + +#: unsubscribeplugininterface.cpp:104 +#, kde-format +msgid "This mailing list supports One-Click Unsubscribe." +msgstr "" + +#: unsubscribeplugininterface.cpp:105 +#, kde-format +msgid "Do you want to unsubscribe?" +msgstr "" + +#: unsubscribeplugininterface.cpp:145 +#, kde-format +msgid "Unsubscribe" +msgstr "" + +#: unsubscribeplugininterface.cpp:146 +#, kde-format +msgid "" +"Allows you to unsubscribe from a mailing list, if the sender supports One-" +"Click Unsubscribe" +msgstr "" + +#: unsubscribeplugininterface.cpp:158 +#, kde-format +msgid "Request Complete" +msgstr "" + +#: unsubscribeplugininterface.cpp:159 +#, kde-format +msgid "The unsubscribe request was successfully sent." +msgstr "" + +#: unsubscribeplugininterface.cpp:164 +#, kde-format +msgid "Unsubscribe Error" +msgstr "" diff --git a/unsubscribemanager.cpp b/unsubscribemanager.cpp new file mode 100644 index 0000000..901973d --- /dev/null +++ b/unsubscribemanager.cpp @@ -0,0 +1,190 @@ +#include + +#include "unsubscribe_debug.h" + +#include "unsubscribemanager.h" +#include "oneclickunsubscribejob.h" + +using namespace MessageViewer; + +UnsubscribeManager::UnsubscribeManager(QObject *parent) + : QObject(parent), + mDkimMgr(DKIMManager(this)) +{ + connect(&mDkimMgr, &DKIMManager::result, this, &UnsubscribeManager::getDkimResult); +} + +UnsubscribeManager::~UnsubscribeManager() = default; + +void UnsubscribeManager::reset() +{ + mDKIMValid = false; + mItemId = -1; + mPostUrl = QUrl(); + mMessage = nullptr; +} + +void UnsubscribeManager::setMessageItem(const Akonadi::Item &item) +{ + if (item.id() == mItemId) + { + // We already have this item, do nothing + return; + } + else if (mItemId != -1) + { + // reset wasn't called first, so call it for them + this->reset(); + } + + // First, we have to have a KMime::Message::Ptr + if (item.hasPayload()) + { + mMessage = item.payload(); + if (mMessage == nullptr) + { + // Sometimes we get nullptr even though item.hasPayload() was true... + qCInfo(UnsubscribePlugin) << "Can't get current email"; + return; + } + mItemId = item.id(); + // Check if we even have Unsubscribe info + mList = MessageCore::MailingList::detect(mMessage); + } + else + { + qWarning(UnsubscribePlugin) << "Received email doesn't seem to be an email"; + } + + // Don't bother checking DKIM if we don't have an unsubscribe URL, or if we + // don't have DKIM enabled + if (!(getUrl().isEmpty()) && MessageViewerSettings::self()->enabledDkim()) + { + mDkimMgr.checkDKim(item); + } +} + +bool UnsubscribeManager::hasMessage() +{ + return !(mMessage == nullptr); +} + +void UnsubscribeManager::getDkimResult(const MessageViewer::DKIMCheckSignatureJob::CheckSignatureResult &checkResult, Akonadi::Item::Id id) +{ + if (id == mItemId) + { + mDKIMValid = checkResult.isValid(); + qCDebug(UnsubscribePlugin) << "Got DKIM result! Valid:" << mDKIMValid; + } + else + { + qCDebug(UnsubscribePlugin) << "Got DKIM result for wrong ID! Wanted" << mItemId << "but got" << id; + } +} + +UnsubscribeManager::Status +UnsubscribeManager::unsubscribeStatus() +{ + if (mMessage != nullptr && mList.features().testFlag(MessageCore::MailingList::Unsubscribe)) + { + if (!oneClickUrl().isEmpty()) + { + // One-Click requires List-Unsubscribe-Post + if (mMessage->hasHeader(LIST_UNSUBSCRIBE_POST_HDR)) + { + if (MessageViewerSettings::self()->enabledDkim() && + !mDKIMValid) + { + return UnsubscribeManager::InvalidOneClick; + } + return UnsubscribeManager::ValidOneClick; + } + } + + return UnsubscribeManager::NoOneClick; + } + + return UnsubscribeManager::None; +} + +void UnsubscribeManager::doOneClick() +{ + auto status = unsubscribeStatus(); + if (status == UnsubscribeManager::InvalidOneClick || status == UnsubscribeManager::ValidOneClick) + { + auto job = new OneClickUnsubscribeJob(mPostUrl, this); + connect(job, &OneClickUnsubscribeJob::result, this, &UnsubscribeManager::checkResult); + job->start(); + } +} + +QUrl UnsubscribeManager::oneClickUrl() +{ + if (mPostUrl.isEmpty() && mList.features().testFlag(MessageCore::MailingList::Unsubscribe)) + { + foreach (QUrl url, mList.unsubscribeUrls()) + { + // Per the RFC, there should only be one HTTPS URL. So grab the + // first one we find + if (url.scheme() == QStringLiteral("https")) + { + mPostUrl = url; + break; + } + } + } + + return mPostUrl; +} + +QUrl UnsubscribeManager::getUrl() +{ + // TODO: Maybe this should be customizable. + // The theory is that HTTPS is most secure, mailto is more likely to be + // secure (MTAs often use TLS these days), and http as last resort. I really + // hope nobody's requesting unsubscribe over IRC... + const QStringList protocols = {"https", "mailto", "http"}; + if (!mPostUrl.isEmpty()) + { + return mPostUrl; + } + else + { + foreach (QString scheme, protocols) + { + foreach (QUrl url, mList.unsubscribeUrls()) + { + if (url.scheme() == scheme) + { + return url; + } + } + } + } + + return QUrl(); +} + +void UnsubscribeManager::checkResult(const Result &result) +{ + bool isSuccess = false; + QString resultStr; + switch (result.Type) + { + case Result::None: + isSuccess = true; + break; + case Result::NetworkError: + resultStr = i18n("Unable to send unsubscribe request: %1", result.ErrorString); + break; + case Result::SslError: + resultStr = i18n("Got one or more SSL errors: %1", result.ErrorString); + break; + default: + resultStr = i18n("Plugin hit an unreachable point"); + } + + Q_EMIT oneClickResult(isSuccess, resultStr); +} + +#include "moc_unsubscribemanager.cpp" \ No newline at end of file diff --git a/unsubscribemanager.h b/unsubscribemanager.h new file mode 100644 index 0000000..de44a23 --- /dev/null +++ b/unsubscribemanager.h @@ -0,0 +1,127 @@ +#ifndef _UNSUBSCRIBEMANAGER_H_ +#define _UNSUBSCRIBEMANAGER_H_ + +#include +#include +#include +#include +#include + +#define LIST_UNSUBSCRIBE_POST_HDR "List-Unsubscribe-Post" +#define LIST_UNSUBSCRIBE_POST_VALUE "List-Unsubscribe=One-Click" + +namespace MessageViewer +{ + class UnsubscribeManager : public QObject + { + Q_OBJECT + public: + explicit UnsubscribeManager(QObject *parent = nullptr); + ~UnsubscribeManager() override; + + struct Result + { + enum _errorType + { + None = 0, + NetworkError, + SslError, + } Type; + QString ErrorString; + }; + + enum Status + { + /// @brief No unsubscribe method is available. + None, + /// @brief Unsubscribe is available, but not as one-click unsubscribe. + NoOneClick, + /** + * @brief One-Click Unsubscribe is available, but DKIM didn't verify. + * + * Note: This state is considered non-compliant with RFC 8058. + */ + InvalidOneClick, + /// @brief One-Click Unsubscribe is available. + ValidOneClick, + }; + + /** + * @brief Sets the current message item. + * + * @param item The current message item. + */ + void setMessageItem(const Akonadi::Item &item); + + /** + * @brief Tests whether the current message item has been set. + */ + bool hasMessage(); + + /** + * @brief Tests if the message has information to programmatically unsubscribe + * + * @return true if the message can be unsubscribed from. + * @return false if the message cannot be unsubscribed from. + */ + Status unsubscribeStatus(); + + /** + * @brief Performs a One-Click unsubscribe. + */ + void doOneClick(); + + /** + * @brief Get the URL for One-Click Unsubscribe. + * + * @return QUrl The URL. If none was found, the URL will be empty. + */ + QUrl oneClickUrl(); + + /** + * @brief Get the best Unsubscribe URL. + * Unlike oneClickUrl, this will check for any usable Unsubscribe URL. + * The search order is currently HTTPS, MAILTO, and HTTP URLs. + * + * @return QUrl The best available Unsubscribe URL, per the above heuristic + */ + QUrl getUrl(); + + /** + * @brief Resets the object's state. + * @remark This does not cancel any running One-Click Unsubscribe jobs. + * + */ + void reset(); + public slots: + /** + * @brief This is used as a slot for DKIMManager::result. + * This signal can only be signalled once-- further signals will be + * ignored. + * + * @param checkResult + * @param id + */ + void getDkimResult(const MessageViewer::DKIMCheckSignatureJob::CheckSignatureResult &checkResult, Akonadi::Item::Id id); + + void checkResult(const Result &result); + + signals: + void oneClickResult(bool isSuccess, const QString &resultString); + + private: + // message info + KMime::Message::Ptr mMessage = nullptr; + MessageCore::MailingList mList; + Akonadi::Item::Id mItemId = -1; + + // Cached by oneClickUrl() + QUrl mPostUrl; + + // Used to check DKIM, for RFC 8058 compliance + DKIMManager mDkimMgr; + bool mDKIMValid = false; + }; +} + +#endif /* !_UNSUBSCRIBEMANAGER_H_ */ \ No newline at end of file diff --git a/unsubscribeplugin.cpp b/unsubscribeplugin.cpp new file mode 100644 index 0000000..025c7be --- /dev/null +++ b/unsubscribeplugin.cpp @@ -0,0 +1,21 @@ +#include "unsubscribeplugin.h" +#include "unsubscribeplugininterface.h" +#include +#include + +using namespace MessageViewer; +K_PLUGIN_CLASS_WITH_JSON(UnsubscribePlugin, "kmail_unsubscribeplugin.json") + +UnsubscribePlugin::UnsubscribePlugin(QObject *parent, const QList &) + : MessageViewer::ViewerPlugin(parent) +{ +} + +ViewerPluginInterface *MessageViewer::UnsubscribePlugin::createView(QWidget *parent, KActionCollection *ac) +{ + MessageViewer::ViewerPluginInterface *view = new MessageViewer::UnsubscribePluginInterface(parent, ac); + return view; +} + +#include "unsubscribeplugin.moc" +#include "moc_unsubscribeplugin.cpp" \ No newline at end of file diff --git a/unsubscribeplugin.h b/unsubscribeplugin.h new file mode 100644 index 0000000..e16267b --- /dev/null +++ b/unsubscribeplugin.h @@ -0,0 +1,23 @@ +#ifndef _UNSUBSCRIBEPLUGIN_H +#define _UNSUBSCRIBEPLUGIN_H + +#include +#include + +namespace MessageViewer +{ + class UnsubscribePlugin : public MessageViewer::ViewerPlugin + { + Q_OBJECT + public: + explicit UnsubscribePlugin(QObject *parent = nullptr, const QList & = QList()); + + [[nodiscard]] ViewerPluginInterface *createView(QWidget *parent, KActionCollection *ac) override; + [[nodiscard]] QString viewerPluginName() const override + { + return QStringLiteral("oneclick-unsubscribe"); + }; + }; +} + +#endif diff --git a/unsubscribeplugininterface.cpp b/unsubscribeplugininterface.cpp new file mode 100644 index 0000000..c1369e0 --- /dev/null +++ b/unsubscribeplugininterface.cpp @@ -0,0 +1,162 @@ +#include "unsubscribe_debug.h" +#include "unsubscribeplugininterface.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MessageViewer; + +// General yes/no confirm dialog +static bool +confirmDialog(const QString &text, const QString &question, bool safe) +{ + QMessageBox msgBox; + msgBox.setText(text); + msgBox.setInformativeText(question); + msgBox.setStandardButtons(QMessageBox::Yes | QMessageBox::No); + msgBox.setIcon(safe ? QMessageBox::Question : QMessageBox::Warning); + msgBox.setDefaultButton(safe ? QMessageBox::Yes : QMessageBox::No); + + return msgBox.exec() == QMessageBox::Yes; +} + +UnsubscribePluginInterface::UnsubscribePluginInterface(QWidget *parent, KActionCollection *ac) + : ViewerPluginInterface(parent), + mParent(parent) +{ + if (ac) + { + // Create the action... + auto action = new QAction(this); + action->setIcon(QIcon::fromTheme(QStringLiteral("news-unsubscribe"))); + action->setIconText(i18n("Unsubscribe")); + action->setWhatsThis(i18n("Allows you to unsubscribe from a mailing list, if the sender supports One-Click Unsubscribe")); + + // ... and add it to the application's collection + ac->addAction(QStringLiteral("oneclick_unsubscribe"), action); + + connect(action, &QAction::triggered, this, &UnsubscribePluginInterface::slotActivatePlugin); + // also add it to our list, for actions() + mActions.append(action); + } + + connect(&mUnsub, &UnsubscribeManager::oneClickResult, this, &UnsubscribePluginInterface::getOneClickResult); +} + +UnsubscribePluginInterface::~UnsubscribePluginInterface() = default; + +QList UnsubscribePluginInterface::actions() const +{ + return mActions; +} + +void UnsubscribePluginInterface::closePlugin() +{ + // Reset the UnsubscribeManager's state. + // XXX: Currently, this doesn't cancel any running One-Click Unsubscribe + // calls! + mUnsub.reset(); +} + +/// @brief Called when the action is clicked or otherwise activated +void UnsubscribePluginInterface::execute() +{ + qCDebug(UnsubscribePlugin) << "-click!-"; + // short-circuit if we're offline + if (!PimCommon::NetworkManager::self()->isOnline()) + { + QMessageBox::critical(mParent, + i18n("Network not available"), + i18n("Please go back online to unsubscribe from this list.")); + return; + } + + if (mUnsub.hasMessage()) + { + switch (mUnsub.unsubscribeStatus()) + { + case UnsubscribeManager::None: + { + // Should not happen + QMessageBox::warning(mParent, + i18n("Can't Unsubscribe"), + i18n("This email doesn't advertise a way to unsubscribe.")); + break; + } + break; + case UnsubscribeManager::NoOneClick: + { + // Load the unsubscribe URL normally + QUrl url = mUnsub.getUrl(); + if (!url.isEmpty()) + { + auto job = new KIO::OpenUrlJob(url); + job->setUiDelegate(KIO::createDefaultJobUiDelegate(KJobUiDelegate::AutoHandlingEnabled, mParent)); + job->start(); + } + break; + } + break; + case UnsubscribeManager::InvalidOneClick: + { + if (confirmDialog( + i18n("The digital signature of this email couldn't be validated."), + i18n("Do you still want to unsubscribe?"), + false)) + { + mUnsub.doOneClick(); + } + break; + } + case UnsubscribeManager::ValidOneClick: + { + if (confirmDialog( + i18n("This mailing list supports One-Click Unsubscribe."), + i18n("Do you want to unsubscribe?"), + true)) + { + mUnsub.doOneClick(); + } + break; + } + } + } +} + +void UnsubscribePluginInterface::setMessageItem(const Akonadi::Item &item) +{ + mUnsub.setMessageItem(item); +} + +/// @brief Triggered when the selected item changes. +/// @param item The new item. +void UnsubscribePluginInterface::updateAction(const Akonadi::Item &item) +{ + qCDebug(UnsubscribePlugin) << "updateAction!?" << mActions.count() << "actions"; + mUnsub.setMessageItem(item); + + mActions.first()->setDisabled(mUnsub.unsubscribeStatus() == UnsubscribeManager::None); +} + +void UnsubscribePluginInterface::getOneClickResult(bool isSuccess, const QString &resultString) +{ + if (isSuccess) + { + QMessageBox::information(mParent, + i18n("Request Complete"), + i18n("The unsubscribe request was successfully sent.")); + } + else + { + QMessageBox::critical(mParent, + i18n("Unsubscribe Error"), + resultString); + } +} + +#include "moc_unsubscribeplugininterface.cpp" \ No newline at end of file diff --git a/unsubscribeplugininterface.h b/unsubscribeplugininterface.h new file mode 100644 index 0000000..48ffc8e --- /dev/null +++ b/unsubscribeplugininterface.h @@ -0,0 +1,39 @@ +#ifndef _UNSUBSCRIBEPLUGININTERFACE_H_ +#define _UNSUBSCRIBEPLUGININTERFACE_H_ + +#include +#include "unsubscribemanager.h" + +namespace MessageViewer +{ + class UnsubscribePluginInterface : public MessageViewer::ViewerPluginInterface + { + Q_OBJECT + public: + explicit UnsubscribePluginInterface(QWidget *parent, KActionCollection *ac = nullptr); + ~UnsubscribePluginInterface() override; + + [[nodiscard]] QList actions() const override; + void closePlugin() override; + void execute() override; + void setMessageItem(const Akonadi::Item &item) override; + void updateAction(const Akonadi::Item &item) override; + [[nodiscard]] ViewerPluginInterface::SpecificFeatureTypes featureTypes() const override + { + return ViewerPluginInterface::NeedMessage; + } + + public slots: + void getOneClickResult(bool isSuccess, const QString &resultString); + + private: + UnsubscribeManager mUnsub; + // Whether we're in the middle of a one-click unsubscribe operation + bool mUnsubscribing = true; + + QList mActions; + QWidget *mParent; + }; +} + +#endif \ No newline at end of file