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'