feat: listmonk sync#519
Conversation
e1c3e4a to
f2257de
Compare
6d2f854 to
7e6c528
Compare
|
Edit: issue in GH-520 and fixed in GH-521 I get this error, but I suspect it is not caused by the PR
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 |
rinkp
left a comment
There was a problem hiding this comment.
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
lastCheckto <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
lastCheckto <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, |
There was a problem hiding this comment.
Because we now will get 3 different sync methods, proposal to reduce the number of commands and instead implement an argument to distinguish them
| 'database:local:syncmembership' => MailingListSyncLocalMembershipCommand::class, | |
| 'database:mailinglist:sync-membership' => MailingListSyncMembershipCommand::class, | |
| 'database:mailinglist:fetch-lists' => MailingListFetchListsCommand::class, |
|
|
||
| class MailingListSyncLocalMembershipCommandFactory implements FactoryInterface | ||
| { | ||
| public function __invoke( |
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
After https://github.com/GEWIS/gewisdb/pull/519/changes#r3176665997, suggestion:
| 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'), |
There was a problem hiding this comment.
It seems you capitalise "listmonk" in all other end-user-visible places
| $this->translate('Last fetch of listmonk lists'), | |
| $this->translate('Last fetch of Listmonk lists'), |
| <!-- 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> |
There was a problem hiding this comment.
| <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()) { |
There was a problem hiding this comment.
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; ?> |
There was a problem hiding this comment.
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.
| <?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'])); | |||
There was a problem hiding this comment.
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 */ |
| ): FrontPageService { | ||
| $apiService = $container->get(ApiService::class); | ||
| $mailmanService = $container->get(MailmanService::class); | ||
| $listmonkService = $container->get(ListmonkService::class); |
There was a problem hiding this comment.
Order (this one is very interesting)
Description
Draft PR for listmonk synchronization. Figuring things out as I go, but suggestions are welcome!
Things:
Related issues/external references
Fixes GH-NNN.
Types of changes