diff --git a/src/components/Editor/Invitees/InviteesListSearch.vue b/src/components/Editor/Invitees/InviteesListSearch.vue
index 2b9645689d..27b3fe461c 100644
--- a/src/components/Editor/Invitees/InviteesListSearch.vue
+++ b/src/components/Editor/Invitees/InviteesListSearch.vue
@@ -47,6 +47,10 @@
{{ option.subtitle }}
+
+
+ {{ $t('calendar', 'This might be a mailing list. Invitations will not work.') }}
+
@@ -62,13 +66,14 @@ import {
NcSelect,
} from '@nextcloud/vue'
import debounce from 'debounce'
+import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import GoogleCirclesCommunitiesIcon from 'vue-material-design-icons/GoogleCirclesCommunities.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 logger from '@/utils/logger.js'
import { randomId } from '@/utils/randomId.js'
@@ -78,6 +83,7 @@ export default {
Avatar,
NcSelect,
GoogleCirclesCommunitiesIcon,
+ AlertCircleOutline,
},
props: {
@@ -154,6 +160,7 @@ export default {
timezoneId: null,
hasMultipleEMails: false,
dropdownName: query,
+ looksLikeMailingList: looksLikeMailingList(query),
})
}
}
@@ -290,6 +297,7 @@ export default {
timezoneId: result.tzid,
hasMultipleEMails,
dropdownName: name + ' ' + email,
+ looksLikeMailingList: looksLikeMailingList(email),
})
})
@@ -330,5 +338,13 @@ export default {
:deep(.vs__search::placeholder) {
text-overflow: ellipsis;
+
+}
+
+.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 069fa0c0dd..127050fb19 100644
--- a/src/utils/attendee.js
+++ b/src/utils/attendee.js
@@ -84,6 +84,94 @@ export function getRoomAttendees(attendees) {
return attendees.filter((attendee) => attendee?.attendeeProperty?.userType === 'ROOM')
}
+/*
+ * 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 3d5c250451..5323d147d8 100644
--- a/tests/javascript/unit/utils/attendee.test.js
+++ b/tests/javascript/unit/utils/attendee.test.js
@@ -7,6 +7,7 @@ import {
addMailtoPrefix,
getRoomAttendees,
isPendingResourceBooking,
+ looksLikeMailingList,
organizerDisplayName,
removeMailtoPrefix,
} from '@/utils/attendee.js'
@@ -37,6 +38,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'