Skip to content

feat: listmonk sync#519

Open
SuperVK wants to merge 20 commits intoGEWIS:mainfrom
SuperVK:feature/listmonk-sync
Open

feat: listmonk sync#519
SuperVK wants to merge 20 commits intoGEWIS:mainfrom
SuperVK:feature/listmonk-sync

Conversation

@SuperVK
Copy link
Copy Markdown
Member

@SuperVK SuperVK commented Mar 28, 2026

Description

Draft PR for listmonk synchronization. Figuring things out as I go, but suggestions are welcome!

Things:

  • Listmonk does not allow automatic creation of API users. So that needs to happen manually. (how does mailman do this exactly?)

Related issues/external references

Fixes GH-NNN.

Types of changes

  • New feature (non-breaking change which adds functionality)

@SuperVK SuperVK force-pushed the feature/listmonk-sync branch from e1c3e4a to f2257de Compare March 31, 2026 16:50
@SuperVK SuperVK force-pushed the feature/listmonk-sync branch from 6d2f854 to 7e6c528 Compare April 29, 2026 18:07
@SuperVK SuperVK marked this pull request as ready for review April 30, 2026 14:28
@rinkp rinkp self-requested a review May 2, 2026 10:40
@rinkp
Copy link
Copy Markdown
Member

rinkp commented May 2, 2026

Edit: issue in GH-520 and fixed in GH-521

I get this error, but I suspect it is not caused by the PR

Last fetch of available listmonk lists was never. Possibly, the mailing list server is down or fetch mechanism is malfunctioning.

user@host:~/GitHub/gewisdb$ git log --max-count=1 
commit ef45965274be40ecdc09fa3b41454e20c1eab567 (HEAD, victor/feature/listmonk-sync)

user@host:~/GitHub/gewisdb$ docker compose logs --tail=15 -t web
web-1  | 2026-05-02T11:03:49.784353666Z 
web-1  | 2026-05-02T11:03:49.784356493Z orm:generate-proxies [--em EM] [--filter FILTER] [--object-manager [OBJECT-MANAGER]] [--] [<dest-path>]
web-1  | 2026-05-02T11:03:49.784359774Z 
web-1  | 2026-05-02T11:03:49.837303931Z [02-May-2026 13:03:49] NOTICE: fpm is running, pid 18
web-1  | 2026-05-02T11:03:49.837515675Z [02-May-2026 13:03:49] NOTICE: ready to handle connections
web-1  | 2026-05-02T11:05:00.068720934Z crond: can't change directory to '/home/www-data': No such file or directory
web-1  | 2026-05-02T11:05:00.068768042Z crond: USER www-data pid  77 cmd { . /code/config/bash.env && /usr/bin/php /code/web database:mailman:syncmembership -f -vv; } > /data/logs/cron-mailman-syncmembership.log 2>&1
web-1  | 2026-05-02T11:10:00.069978483Z crond: can't change directory to '/home/www-data': No such file or directory
web-1  | 2026-05-02T11:10:00.070008837Z crond: USER www-data pid  82 cmd { . /code/config/bash.env && /usr/bin/php /code/web database:listmonk:syncmembership -f -vv; } > /data/logs/cron-listmonk-syncmembership.log 2>&1
web-1  | 2026-05-02T11:30:00.072926087Z crond: can't change directory to '/home/www-data': No such file or directory
web-1  | 2026-05-02T11:30:00.072950848Z crond: USER www-data pid 224 cmd { . /code/config/bash.env && /usr/bin/php /code/web check:membership:renewal:graduate; } > /data/logs/cron-check-renewal.log 2>&1
web-1  | 2026-05-02T11:50:00.076091054Z crond: can't change directory to '/home/www-data': No such file or directory
web-1  | 2026-05-02T11:50:00.076126173Z crond: USER www-data pid 229 cmd { . /code/config/bash.env && /usr/bin/php /code/web database:mailman:fetch; } > /data/logs/cron-mailman-fetch.log 2>&1
web-1  | 2026-05-02T11:55:00.077334456Z crond: can't change directory to '/home/www-data': No such file or directory
web-1  | 2026-05-02T11:55:00.077362961Z crond: USER www-data pid 242 cmd { . /code/config/bash.env && /usr/bin/php /code/web database:listmonk:fetch; } > /data/logs/cron-listmonk-fetch.log 2>&1

Copy link
Copy Markdown
Member

@rinkp rinkp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing, thank you! Some remarks below

I realised we never wrote down some of the discussed constraints, so I have created GH-525.

There are a few bugs, pointed out in individual comments. Major remarks (repeated) below.

Structure is okay, now that we will get several syncing methods, I do propose to change the commands here to avoid duplicate code/overhead a bit:
https://github.com/GEWIS/gewisdb/pull/519/changes#r3176665997

One of our goals is a quick development environment for whomever wants to work on GEWISDB. As such, the automatic setup of the API user and initial sync with DB is necessary. (Tested) proposal given here.

Test overview

I checked the following scenario's (after fixing the bugs pointed out above):

  • Clean scenario, basic case
    • docker volume prune -af, make rundev, make migrate, make seed (with the proposed changes)
    • Created mailing list through UI
    • Normal subscription
      • Subscribe member in GEWISDB
      • docker compose exec web ./web database:listmonk:syncmembership -f -vv
      • Verify member exists on listmonk side
      • Check that synced flag is set
    • Regular unsubscription
      • Unsubscribe member in GEWISDB
      • docker compose exec web ./web database:listmonk:syncmembership -f -vv
      • Verify member does not exist on Listmonk side
      • Verify that unsubscription is processed on GEWISDB side
    • External unsubscription
      • Subscribe member in GEWISDB
      • docker compose exec web ./web database:listmonk:syncmembership -f -vv
      • Verify member exists on listmonk side
      • Remove member on listmonk side
      • Setting MailingListMember lastSyncOn to <1 day ago
      • docker compose exec web ./web database:listmonk:syncmembership -f -vv
      • Verify now also unsubscribed in GEWISDB
    • External subscription (rare, but supported)
      • Sign up and confirm member in Listmonk
      • Change ListmonkMailingList lastCheck to <1 day ago
      • docker compose exec web ./web database:listmonk:syncmembership -f -vv
      • Verify now also subscribed in GEWISDB, marked as synced
    • External subscription of unknown email
      • Sign up and confirm non-GEWISDB-member in Listmonk
      • Change ListmonkMailingList lastCheck to <1 day ago
      • docker compose exec web ./web database:listmonk:syncmembership -f -vv
      • Verify that the address was removed in listmonk
  • Clean scenario, manually setting a mailman list
    • Check if listmonk list cannot be set at the same time, fail. Logic in Form does not work, and frontend does not hide the option to set a listmonk list if the mailman list is set. After saving, both are present in the database. If you ensure the form does not allow saving the list like that, I think it is fine. I thought of implementing a preupdate and prepersist function in the model, but that seems slightly overkill.
    • Switching from mailman list to listmonk list
      • Create list in GEWISDB
      • Link list to Mailman
      • Subscribe member in GEWISDB
      • docker compose exec web ./web database:mailman:syncmembership -vvv -f
      • Verify member status is synced
      • Unlink mailman list
      • Link to listmonk (through DB) because of the impossibility of saving a list
      • Next sync will cause unsubscriptions in GEWISDB. Instead, expected subscriptions in listmonk. In contrast to what I said before, linking a listmonk list (or mailman list for that matter), should set "toBeCreated = true" on all rows on the GEWISDB side. That will subscribe everyone who is not subscribed in the backend yet.
        For the initial mailman links this was not done (because the mailman backend was considered the source of truth), but for generic operations this seems to be the desired behaviour.

'laminas-cli' => [
'commands' => [
'database:mailinglist:maintenance' => MailingListMaintenanceCommand::class,
'database:local:syncmembership' => MailingListSyncLocalMembershipCommand::class,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we now will get 3 different sync methods, proposal to reduce the number of commands and instead implement an argument to distinguish them

Suggested change
'database:local:syncmembership' => MailingListSyncLocalMembershipCommand::class,
'database:mailinglist:sync-membership' => MailingListSyncMembershipCommand::class,
'database:mailinglist:fetch-lists' => MailingListFetchListsCommand::class,


class MailingListSyncLocalMembershipCommandFactory implements FactoryInterface
{
public function __invoke(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer relevant after https://github.com/GEWIS/gewisdb/pull/519/changes#r3176665997, but this would have been the fix for the psalm error. You ignore MissingNativeTypeHint in phpcs.xml.dist, but not the MissingAnyTypeHint

Suggested change
public function __invoke(
/**
* @param string $requestedName
*/
public function __invoke(

*/30 * * * * { . /code/config/bash.env && /usr/bin/php /code/web check:membership:renewal:graduate; } > /data/logs/cron-check-renewal.log 2>&1
0 2 * * * { . /code/config/bash.env && /usr/bin/php /code/web database:prospective-members:delete-expired; } > /data/logs/cron-delete-prospective.log 2>&1
55 * * * * { . /code/config/bash.env && /usr/bin/php /code/web database:mailman:fetch; } > /data/logs/cron-mailman-fetch.log 2>&1
50 * * * * { . /code/config/bash.env && /usr/bin/php /code/web database:mailman:fetch; } > /data/logs/cron-mailman-fetch.log 2>&1
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After https://github.com/GEWIS/gewisdb/pull/519/changes#r3176665997, suggestion:

Suggested change
50 * * * * { . /code/config/bash.env && /usr/bin/php /code/web database:mailman:fetch; } > /data/logs/cron-mailman-fetch.log 2>&1
50 * * * * { . /code/config/bash.env && /usr/bin/php /code/web database:mailinglist:fetch-lists -b "mailman"; } > /data/logs/cron-mailinglist-fetch-lists-mailman.log 2>&1

'%s: %s',
$this->translate('Last fetch of mailman lists'),
$mailmanLastFetch?->format('Y-m-d H:i:s') ?: $this->translate('never')
$this->translate('Last fetch of listmonk lists'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems you capitalise "listmonk" in all other end-user-visible places

Suggested change
$this->translate('Last fetch of listmonk lists'),
$this->translate('Last fetch of Listmonk lists'),

Comment thread phpcs.xml.dist
<!-- Legacy code that cannot be easily changed, so ignore for now -->
<rule ref="Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps">
<exclude-pattern>module/Database/src/Model/Decision.php</exclude-pattern>
<exclude-pattern>module/Database/src/Model/ListmonkMailingList.php</exclude-pattern>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<exclude-pattern>module/Database/src/Model/ListmonkMailingList.php</exclude-pattern>

): ?MemberModel {
// Check if we are performing a sync or not.
if ($this->mailmanService->isSyncLocked()) {
if ($this->mailmanService->isSyncLocked() || $this->listmonkService->isSyncLocked()) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels wrong for the mailman sync to depend on the listmonk sync and vice versa.
I just realise that it may be possible to perform both syncs at the same time. Neither class should touch a list which the other is also processing, so I think it suffices here to simply only check if mailman sync is running.

<?= $this->formElementErrors($element) ?>
</div>
</div>
<?php endif; ?>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases where you want to show both fields? Or do you want to enforce unlinking the mailman list first, then saving and then setting the listmonk list.

Suggested change
<?php endif; ?>
<?php else: ?>

@@ -66,6 +68,7 @@ public function editList(
$list->setOnForm(boolval($data['onForm']));
$list->setDefaultSub(boolval($data['defaultSub']));
$list->setMailmanList($this->getMailmanService()->getMailingList($data['mailmanList']));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the mailman field is not present during an update, this call now fails ensuring that a mailing list cannot be updated unless it has a mailman list set.

[2026-05-03 14:28:53] gewisdb.ERROR: TypeError: Database\Service\Mailman::getMailingList(): Argument  #1 ($mailmanId) must be of type string, null given, called in /code/module/Database/src/Service/MailingList.php on line 70 and defined in /code/module/Database/src/Service/Mailman.php:359 

Stack trace: 
#0 /code/module/Database/src/Service/MailingList.php(70): Database\Service\Mailman->getMailingList(NULL) 
#1 /code/module/Database/src/Controller/SettingsController.php(126): Database\Service\MailingList->editList(Object(Database\Model\MailingList), Array) 
#2 /code/vendor/laminas/laminas-mvc/src/Controller/AbstractActionController.php(72): Database\Controller\SettingsController->editListAction() 
#3 /code/vendor/laminas/laminas-eventmanager/src/EventManager.php(320): Laminas\Mvc\Controller\AbstractActionController->onDispatch(Object(Laminas\Mvc\MvcEvent)) 
#4 /code/vendor/laminas/laminas-eventmanager/src/EventManager.php(178): Laminas\EventManager\EventManager->triggerListeners(Object(Laminas\Mvc\MvcEvent), Object(Closure)) 
#5 /code/vendor/laminas/laminas-mvc/src/Controller/AbstractController.php(105): Laminas\EventManager\EventManager->triggerEventUntil(Object(Closure), Object(Laminas\Mvc\MvcEvent)) 
#6 /code/vendor/laminas/laminas-mvc/src/DispatchListener.php(117): Laminas\Mvc\Controller\AbstractController->dispatch(Object(Laminas\Http\PhpEnvironment\Request), Object(Laminas\Http\PhpEnvironment\Response)) 
#7 /code/vendor/laminas/laminas-eventmanager/src/EventManager.php(320): Laminas\Mvc\DispatchListener->onDispatch(Object(Laminas\Mvc\MvcEvent)) 
#8 /code/vendor/laminas/laminas-eventmanager/src/EventManager.php(178): Laminas\EventManager\EventManager->triggerListeners(Object(Laminas\Mvc\MvcEvent), Object(Closure)) 
#9 /code/vendor/laminas/laminas-mvc/src/Application.php(319): Laminas\EventManager\EventManager->triggerEventUntil(Object(Closure), Object(Laminas\Mvc\MvcEvent)) 
#10 /code/public/index.php(20): Laminas\Mvc\Application->run() 
#11 {main} [] []

$mailingListMemberMapper = $container->get(MailingListMemberMapper::class);
/** @var MailmanService $mailmanService */
$mailmanService = $container->get(MailmanService::class);
/** @var ListmonkService $listmonkService */
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order

): FrontPageService {
$apiService = $container->get(ApiService::class);
$mailmanService = $container->get(MailmanService::class);
$listmonkService = $container->get(ListmonkService::class);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order (this one is very interesting)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants