From b4fec1f0976d77c96060e7a8b1247ca88e82c1aa Mon Sep 17 00:00:00 2001
From: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com>
Date: Thu, 12 Mar 2026 15:44:23 +0100
Subject: [PATCH] feat: try to detect mailing lists as event attendees
The backend doesn't differenciate between individual email and a list.
It will just send the invite. For a list, the invite is bogus and can't
really be answered. We should warn the user.
Signed-off-by: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com>
---
.../Editor/Invitees/InviteesListSearch.vue | 17 +++++-
src/utils/attendee.js | 55 +++++++++++++++++++
tests/javascript/unit/utils/attendee.test.js | 46 ++++++++++++++++
3 files changed, 117 insertions(+), 1 deletion(-)
diff --git a/src/components/Editor/Invitees/InviteesListSearch.vue b/src/components/Editor/Invitees/InviteesListSearch.vue
index aeb7da55fa..26f0721b5f 100644
--- a/src/components/Editor/Invitees/InviteesListSearch.vue
+++ b/src/components/Editor/Invitees/InviteesListSearch.vue
@@ -46,6 +46,10 @@
{{ option.subtitle }}
+
+
+ {{ $t('calendar', 'This might be a mailing list. Invitations will not work.') }}
+
@@ -62,12 +66,13 @@ import {
} from '@nextcloud/vue'
import debounce from 'debounce'
import GoogleCirclesCommunitiesIcon from 'vue-material-design-icons/GoogleCirclesCommunities.vue'
+import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import {
circleGetMembers,
circleSearchByName,
} from '../../../services/circleService.js'
import isCirclesEnabled from '../../../services/isCirclesEnabled.js'
-import { removeMailtoPrefix } from '../../../utils/attendee.js'
+import { looksLikeMailingList, removeMailtoPrefix } from '../../../utils/attendee.js'
import { randomId } from '../../../utils/randomId.js'
export default {
@@ -76,6 +81,7 @@ export default {
Avatar,
NcSelect,
GoogleCirclesCommunitiesIcon,
+ AlertCircleOutline,
},
props: {
@@ -145,6 +151,7 @@ export default {
timezoneId: null,
hasMultipleEMails: false,
dropdownName: query,
+ looksLikeMailingList: looksLikeMailingList(query),
})
}
}
@@ -281,6 +288,7 @@ export default {
timezoneId: result.tzid,
hasMultipleEMails,
dropdownName: name + ' ' + email,
+ looksLikeMailingList: looksLikeMailingList(email),
})
})
@@ -318,4 +326,11 @@ export default {
:deep(.avatardiv) {
overflow: visible !important;
}
+
+.invitees-search-list-item__warning {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-warning);
+}
diff --git a/src/utils/attendee.js b/src/utils/attendee.js
index a74bcea5da..24c24814d7 100644
--- a/src/utils/attendee.js
+++ b/src/utils/attendee.js
@@ -57,6 +57,61 @@ export function organizerDisplayName(organizer) {
return removeMailtoPrefix(organizer.uri)
}
+/**
+ * Heuristically check if an email address looks like a mailing list address
+ *
+ * @param {string} email Email address to check (with or without mailto: prefix)
+ * @return {boolean} True if the address looks like a mailing list
+ */
+export function looksLikeMailingList(email) {
+ if (typeof email !== 'string') {
+ return false
+ }
+
+ const address = removeMailtoPrefix(email).toLowerCase()
+ const atIndex = address.indexOf('@')
+ if (atIndex === -1) {
+ return false
+ }
+
+ const local = address.slice(0, atIndex)
+ const domain = address.slice(atIndex + 1)
+
+ const exactMatches = new Set([
+ 'list', 'lists', 'ml', 'announce', 'announcements', 'noreply', 'no-reply',
+ 'newsletter', 'newsletters', 'mailer-daemon', 'postmaster', 'sympa',
+ 'majordomo', 'listserv', 'mailman', 'dmarc', 'bounce', 'bounces',
+ 'subscribe', 'unsubscribe',
+ ])
+ if (exactMatches.has(local)) {
+ return true
+ }
+
+ const suffixes = [
+ '-bounces', '-request', '-subscribe', '-unsubscribe', '-owner', '-help',
+ '-announce', '-devel', '-discuss', '-commits', '-bugs', '-patches',
+ '-users', '-list', '+bounces', '+subscribe',
+ ]
+ if (suffixes.some((s) => local.endsWith(s))) {
+ return true
+ }
+
+ const knownDomains = new Set([
+ 'googlegroups.com', 'groups.io', 'freelists.org',
+ 'yahoogroups.com', 'listserv.com', 'topica.com',
+ ])
+ if (knownDomains.has(domain)) {
+ return true
+ }
+
+ const knownSubdomainPrefixes = ['lists.', 'ml.', 'listserv.', 'mailman.', 'sympa.']
+ if (knownSubdomainPrefixes.some((p) => domain.startsWith(p))) {
+ return true
+ }
+
+ return false
+}
+
/**
* Check if the current user is an attendee
*
diff --git a/tests/javascript/unit/utils/attendee.test.js b/tests/javascript/unit/utils/attendee.test.js
index 1964981270..c243a5e829 100644
--- a/tests/javascript/unit/utils/attendee.test.js
+++ b/tests/javascript/unit/utils/attendee.test.js
@@ -5,6 +5,7 @@
import {
addMailtoPrefix,
+ looksLikeMailingList,
organizerDisplayName,
removeMailtoPrefix,
} from '../../../../src/utils/attendee'
@@ -33,6 +34,51 @@ describe('utils/attendee test suite', () => {
expect(addMailtoPrefix(undefined)).toEqual("mailto:")
})
+ describe('looksLikeMailingList', () => {
+ it('detects exact local-part matches', () => {
+ expect(looksLikeMailingList('announce@example.com')).toBe(true)
+ expect(looksLikeMailingList('mailman@example.org')).toBe(true)
+ expect(looksLikeMailingList('noreply@example.com')).toBe(true)
+ expect(looksLikeMailingList('lists@example.com')).toBe(true)
+ })
+
+ it('detects local-part suffix matches', () => {
+ expect(looksLikeMailingList('dev-bounces@example.org')).toBe(true)
+ expect(looksLikeMailingList('project-request@example.com')).toBe(true)
+ expect(looksLikeMailingList('calendar-users@example.com')).toBe(true)
+ expect(looksLikeMailingList('app+bounces@example.com')).toBe(true)
+ })
+
+ it('detects known mailing list domains', () => {
+ expect(looksLikeMailingList('group@googlegroups.com')).toBe(true)
+ expect(looksLikeMailingList('list@groups.io')).toBe(true)
+ expect(looksLikeMailingList('user@freelists.org')).toBe(true)
+ })
+
+ it('detects known mailing list subdomains', () => {
+ expect(looksLikeMailingList('user@lists.nextcloud.com')).toBe(true)
+ expect(looksLikeMailingList('someone@mailman.apache.org')).toBe(true)
+ expect(looksLikeMailingList('foo@sympa.example.com')).toBe(true)
+ })
+
+ it('handles mailto: prefix', () => {
+ expect(looksLikeMailingList('mailto:announce@example.com')).toBe(true)
+ expect(looksLikeMailingList('mailto:john@example.com')).toBe(false)
+ })
+
+ it('returns false for regular email addresses', () => {
+ expect(looksLikeMailingList('john@example.com')).toBe(false)
+ expect(looksLikeMailingList('dev@example.com')).toBe(false)
+ expect(looksLikeMailingList('alice@company.com')).toBe(false)
+ })
+
+ it('returns false for invalid input', () => {
+ expect(looksLikeMailingList('notanemail')).toBe(false)
+ expect(looksLikeMailingList(null)).toBe(false)
+ expect(looksLikeMailingList(undefined)).toBe(false)
+ })
+ })
+
it('should extract a display name of an organizer', () => {
const commonName = 'My Name'
const uri = 'uri@test.com'