Built 24 Jul 2026 BST
Live-membership replacement for ~50 Outlook contact groups in the zero mailbox — send to groups with always-current membership, no more manual weekly rebuilds. v2.0 adds a dedicated People screen, Thursday send-splitting into two independent batches, automatic BCC-locking for external addresses, per-group history, and paste-to-replace bulk roster updates.
<your SharePoint site URL>.Add these in the Data panel before pasting any YAML:
| Connection | Type |
|---|---|
| 'BM Groups' | SharePoint → your site → BM Groups |
| 'BM Memberships' | SharePoint → your site → BM Memberships |
| 'BM Maintainers' | SharePoint → your site → BM Maintainers |
| 'BM History' | SharePoint → your site → BM History |
| Office365Users | Office 365 Users connector |
BM Groups| Column name | Type | Notes |
|---|---|---|
| Title | Single line of text | Built-in — the group name, e.g. "All Branch Staff". Not Required. |
| SortOrder | Number | Optional display order. Not Required. |
| ThursdayBatch | Choice | Values: None, Batch 1, Batch 2. Default None. Replaces the old single Thursday flag — the weekly send is split into two independent batches because one BCC of everyone overloads some recipients' mail servers. A group can only be in one batch (or neither), so the two sends never share a recipient. |
| Private | Yes/No | Default No. Private groups are hidden from the Send Email screen for anyone not in BM Maintainers, but are still included in Thursday batch sends. |
| OfficialSensitiveApproved | Yes/No | Default No. Shown as a pill/tag to everyone, not just maintainers — lets anyone see at a glance whether OFFICIAL-SENSITIVE material can go to this group. |
| OfficialUseOnly | Yes/No | Default No. Independent of OfficialSensitiveApproved — a group can carry either, both, or neither. |
| Notes | Multiple lines of text (plain text, not rich text) | Optional. Shown on the group card and to everyone on the Send Email screen so people can sanity-check who they're about to email. |
| ManuallySetModified | Date and Time (new) | Not Required, leave blank by default. Lets a maintainer override the "Last modified" shown on the card — useful for groups migrated from an old system where the real last-changed date predates this app. Tick "Override last modified date" when editing a group to set it; untick to go back to using SharePoint's own Modified date automatically. |
The old Active column has been removed entirely — it's not used anywhere in v2.0. If you built v1.0 already, delete that column.
"Last modified" note: the app shows SharePoint's built-in Modified date, and every formula that changes membership (add/remove/replace) also does a harmless self-Patch on the group's own Title so Modified reflects membership changes too, not just edits to the group record itself.
Versioning: List Settings → Versioning Settings → "Create a version each time you edit an item" = Yes.
Permissions: List Settings → Permissions for this list → Stop Inheriting Permissions → grant Edit to the two maintainers, Read to everyone else on the site.
BM Memberships| Column name | Type | Notes |
|---|---|---|
| Title | Single line of text | Built-in — kept as a mirror of Name purely so SharePoint's own default list view still shows something meaningful. The app itself never reads Title — as of v2.34, Name is the real field the app uses everywhere (display, search, sort, rename-cascade). Not Required. |
| Name | Single line of text (new in v2.34) | The person's display name — the field the app actually reads and writes. Not Required. |
| JMC | Single line of text (new in v2.34) | Bde/Area from the standard import sheet. Optional — blank hides the field on cards. Not Required. |
| County/Contingent | Single line of text (new in v2.34) | County/District from the standard import sheet. The column name itself contains a slash, so in Power Fx it must be referenced as ThisItem.'County/Contingent' (quoted). Optional — blank hides the field on cards. Not Required. |
| Single line of text | Store lowercase. Not Required. | |
| GroupName | Single line of text | Must match a 'BM Groups'.Title value exactly. Plain text, not a Lookup column — this keeps every filter delegable and avoids the Lookup-column delegation issues this app family has hit before. Not Required. |
| Notes | Single line of text | Optional per-person note, editable from Manage Groups or Manage People. |
One row per person-per-group (deliberately denormalised — a person in 3 groups is 3 rows). This mirrors how Outlook contact groups actually work and keeps every query in the app a plain, delegable Filter. The new Manage People screen is a view over this same table grouped by email — no separate "people" list needed. JMC and County/Contingent genuinely belong to the membership row (not the person), since the same person could have different regional metadata in different groups — Manage People's per-person card picks whichever membership row happens to be found first as a reasonable approximation.
Name column, existing membership rows still have their old display name in Title and a blank Name. Backfill Name from Title once before testing the new screens, otherwise Manage People rows, member popups, search, and name sorting can appear blank even though the membership rows still exist.Versioning: enabled, same as BM Groups — gives you a native audit trail as a backstop; the app-level BM History list below is the human-readable one you'll actually use day to day.
Permissions: same unique-permission pattern as BM Groups — Edit for the two maintainers, Read for everyone else.
The Replace all (paste) tab inside Manage Members now recognises two formats automatically, based on how many tab-separated columns each pasted row has:
| Sheet column | Maps to |
|---|---|
| Bde / Area | JMC |
| County / District | County/Contingent |
| Rank | kept as-is, first word of Name |
| First Name | normalised to Proper Case, second part of Name |
| Surname | forced to UPPERCASE, third part of Name |
| Appointment | ignored on import |
Example: Maj / Victor / HUGHES → Name = "Maj Victor HUGHES". A header row is fine to include in the paste — it fails the email-format check and gets silently skipped, the same way blank/invalid rows always have. The old two-column Name/Email paste still works exactly as before for groups that don't have this structured data — the parser just checks the column count per pasted block and picks the matching format.
BM Maintainers| Column name | Type | Notes |
|---|---|---|
| Title | Single line of text | Built-in — the maintainer's name. |
| Single line of text | Store lowercase. This is what the app checks against the signed-in user to decide whether to show Manage Groups / Manage People. |
This list exists specifically so adding a future maintainer is a one-row add to a SharePoint list, not an app edit — no formula change, no republish. Add the two starting rows yourself:
| Title | |
|---|---|
| Maintainer 1 | maintainer1@yourdomain |
| Maintainer 2 | maintainer2@yourdomain |
Permissions: anyone who should use the in-app Manage Maintainers button needs Edit access to this list, because Power Apps cannot bypass SharePoint list permissions. Everyone else only needs Read access so the app can check User().Email. The app blocks removing yourself and blocks removing the final maintainer, but SharePoint permissions are still the hard gate.
BM History (new in v2.0)| Column name | Type | Notes |
|---|---|---|
| Title | Single line of text | Built-in — the human-readable event description, e.g. "Jane Doe added (jane.doe@mod.gov.uk)" or "Bulk replace via paste: 12 removed, 15 added". |
| GroupName | Single line of text | Matches a 'BM Groups'.Title value — which group this event belongs to. |
| EventType | Choice | Values: Created, Added, Removed, Edited, Replaced. Not currently displayed in the app UI but useful for future filtering/reporting. |
One row per membership/group event. The app writes to this list every time someone is added, removed, a group is created/edited, or a roster is bulk-replaced. The 🕘 history icon on each group reads this list filtered by GroupName, newest first — this is what answers "who was added or removed, and when" over time. No custom timestamp column needed; the built-in Created date is used directly.
Versioning: not needed — this list is itself the audit trail; individual rows are never edited after creation.
Permissions: same as BM Groups/BM Memberships — Edit for maintainers (the app writes here on their behalf), Read for everyone (the history icon is visible on the Send Email screen too, not just Manage Groups).
BM Saved Selections (new in v2.35)| Column name | Type | Notes |
|---|---|---|
| Title | Single line of text | Readable mirror only, written as selection name plus group name. |
| SelectionName | Single line of text | Name of the reusable saved send set. |
| GroupName | Single line of text | One row per group included in that saved set. Must match a BM Groups title. |
The Send Email screen uses this list to save, load, and delete reusable group selections. Add this SharePoint list as a data source before pasting scrSendEmail v2.26.
Paste into App → OnStart in Power Apps Studio. This is never part of any screen's YAML. Check varModDomain matches your real domain — it's set to mod.gov.uk below.
Set(varModDomain, "mod.gov.uk");
Set(varMyEmail, Lower(User().Email));
ClearCollect(colMaintainers, 'BM Maintainers');
Set(varIsMaintainer, CountRows(Filter(colMaintainers, Lower(Email) = varMyEmail)) > 0);
ClearCollect(colGroups, AddColumns(Sort('BM Groups', SortOrder, SortOrder.Ascending), Ticked, false));
ClearCollect(colMemberships, Filter('BM Memberships', false)); ForAll(colGroups As grp, Collect(colMemberships, Filter('BM Memberships', GroupName = grp.Title)));
ClearCollect(colThursdayBatchOptions, ["None", "Batch 1", "Batch 2"]);
Set(varRecipientField, "bcc");
Set(varBccForcedByBatch, false);
Set(varBccForced, false);
Set(varPreviewGroupID, Blank());
Set(varShowLightbox, false);
Set(varLightboxMode, "");
Set(varLightboxGroupID, Blank())
Paste each screen in order. Create a new blank screen first, name it exactly as shown, then paste.
Small property-level tweaks that are quicker to make directly in Studio than to re-paste a whole screen for. Select the named control, open its property list (Advanced tab, or the dropdown at the top of the formula bar), and edit only the property named — leave everything else on the control alone. This list grows as new ones come up; check it before doing a full delete-and-repaste for something small.
drpEditThursdayBatch opens blank instead of defaulting to NoneFor existing groups, the old Default formula only looked for the raw stored SharePoint choice value. If that value is blank, stale in colGroups, or has whitespace/casing differences, the dropdown cannot find a matching row in colThursdayBatchOptions and renders blank. Select scrManageGroups → drpEditThursdayBatch and replace the Default property with:
LookUp(
colThursdayBatchOptions,
Lower(Trim(Value)) =
Lower(Trim(Coalesce(LookUp(colGroups, ID = varManageGroupID).ThursdayBatch.Value, "None")))
)
DisplayMode.View combined with OnSelectDisplayMode.View disables interaction entirely on a Classic/Button, not just its hover/press styling — a button with both properties set looks completely normal but never responds to a tap, with no error anywhere. Fix: select the control, delete its DisplayMode property so it goes back to default. Nothing else on the control needs to change.
| Screen | Control | What it's for |
|---|---|---|
| scrSendEmail | recCheckBox | Group-tick checkbox overlay on each card |
| scrSendEmail | recLightboxBackdrop | Click outside the preview/history/batch popup to close it |
| scrManageGroups | recResultTap | Tapping a GAL search result to add them |
| scrManageGroups | recLightboxBackdrop | Click outside the Manage Members lightbox to close it |
| scrManagePeople | recPersonRowFrame | Tapping a person row to select them |
| scrManagePeople | recLightboxBackdrop | Click outside the Add Person/Remove Leaver lightbox to close it |
galGroups' own OnSelect (Set(varPreviewGroupID, ThisItem.ID)) never fired, because its child recRowFrame covers the entire card. Correction, found in v2.10 below: the real reason was that recRowFrame still had DisplayMode: =DisplayMode.View left over from before this app's DisplayMode scrub — it hadn't been caught earlier because at scrub time it had no OnSelect yet, so it didn't match the "View + OnSelect" search. It still fully absorbed the tap (per the v2.8 entry above) without being able to act on it, which is why the gallery's own OnSelect never got a chance to fire either.
Fix on scrSendEmail, gallery galGroups → child recRowFrame: add a new OnSelect property with the formula Set(varPreviewGroupID, ThisItem.ID), and delete its DisplayMode property if it's still set to View. Both steps are needed together. The gallery's own (now-dead) OnSelect can be deleted, but leaving it doesn't hurt anything.
Once a background control like recRowFrame has a working OnSelect, any Label sitting on top of it (group name, member count, notes line, etc.) still blocks the tap from reaching it — in Power Apps, a control fully occupies its own space for hit-testing regardless of whether it has any click behavior of its own, so a Label with no OnSelect does not let clicks pass through to whatever's behind it. The practical result: only the empty gaps around the text respond, not the text itself.
Fix (built into the YAML as of v2.10, no manual action needed if you re-paste): rather than repeating the same OnSelect on every Label — fragile, easy to miss one — a single fully transparent Button (Fill: =RGBA(0,0,0,0), no border, full row size) sits in front of the Labels but behind any small control that needs its own distinct click (a checkbox, an icon). Being transparent, it doesn't hide anything visually; being in front of the Labels, it catches every tap across the whole row regardless of where the text sits. Z-order in this app's YAML is simple: later children render in front of earlier ones, so the order that makes this work is: decorative background → Labels → transparent click-catcher → any small distinct-action controls (checkbox/icons) last.
| Screen | New control | Sits in front of | Sits behind |
|---|---|---|---|
| scrSendEmail | recRowClickCatcher | recRowFrame, lblGroupName, lblMemberMeta, lblGroupNotesLine | recCheckBox, icoViewMembers, icoViewHistory (unaffected, still independently clickable) |
| scrManagePeople | recPersonRowClickCatcher | recPersonRowFrame, lblPersonRowName, lblPersonRowEmail | nothing else in this row — it's the frontmost control |
These are written as targeted property edits on named controls (find the control in the tree, open its formula bar, paste in just the one property) plus a short list of brand-new controls to insert. Nothing here requires deleting or re-pasting a whole screen, so any manual text-size/spacing tweaks you've already made elsewhere are untouched.
Cause: the buttons always forced BCC-locked mode even when zero groups actually had that Thursday Batch set, which read as "broken" rather than "nothing to select yet."
| Control | Property | New formula |
|---|---|---|
btnThursdayBatch1 | OnSelect | =If(CountRows(Filter(colGroups, Lower(Trim(ThursdayBatch.Value)) = "batch 1")) = 0, Notify("No groups are currently set to Thursday Batch 1. Set a group's Thursday Batch in Manage Groups first.", NotificationType.Warning), UpdateIf(colGroups, true, {Ticked: false}); UpdateIf(colGroups, Lower(Trim(ThursdayBatch.Value)) = "batch 1", {Ticked: true}); Set(varBccForcedByBatch, true); Set(varBccForced, true)) |
btnThursdayBatch2 | OnSelect | Same shape as above with every "batch 1" → "batch 2" and the notify text saying "Batch 2". |
galLightboxMembers | Items | In the existing Switch(varLightboxMode, ...) formula, in the "batch1" and "batch2" branches, change grp.ThursdayBatch.Value = "Batch 1" (and "Batch 2") to Lower(Trim(grp.ThursdayBatch.Value)) = "batch 1" (and "batch 2") — same trim/case-insensitive treatment, for the batch-preview popup to match what the buttons now select. |
New control to insert: an Icon, positioned just inside the right edge of txtGroupSearch.
| Property | Value |
|---|---|
| Name | icoClearGroupSearch |
| Icon | =Icon.Cancel |
| Visible | =!IsBlank(Trim(txtGroupSearch.Text)) |
| Color | =RGBA(160,160,160,1) |
| Tooltip | ="Clear search" |
| OnSelect | =Reset(txtGroupSearch) |
| X / Y / Width / Height | 668 / 131 / 22 / 22 |
New control in the edit-group lightbox, next to Cancel. Uses the same tap-twice-to-confirm pattern as the member/maintainer trash icons already in this app.
| Property | Value |
|---|---|
| Name | btnDeleteGroup (ModernButton) |
| Text | =If(varConfirmDeleteGroupArmed, "Tap again to confirm - deletes " & CountRows(Filter(colMemberships, Lower(Trim(GroupName)) = Lower(Trim(LookUp(colGroups, ID = varManageGroupID).Title)))) & " member(s)", "Delete group") |
| Visible | =varShowLightbox && varLightboxMode = "edit" && !IsBlank(varManageGroupID) |
| BasePaletteColor | =RGBA(163,45,45,1) |
| OnSelect | =If(!varConfirmDeleteGroupArmed, Set(varConfirmDeleteGroupArmed, true), With({deletedTitle: LookUp(colGroups, ID = varManageGroupID).Title}, RemoveIf('BM Memberships', Lower(Trim(GroupName)) = Lower(Trim(deletedTitle))); RemoveIf('BM History', Lower(Trim(GroupName)) = Lower(Trim(deletedTitle))); Remove('BM Groups', LookUp('BM Groups', ID = varManageGroupID)); ClearCollect(colGroups, AddColumns(Sort('BM Groups', SortOrder, SortOrder.Ascending), Ticked, false)); ClearCollect(colMemberships, Filter('BM Memberships', false)); ForAll(colGroups As grp, Collect(colMemberships, Filter('BM Memberships', GroupName = grp.Title))); Set(varConfirmDeleteGroupArmed, false); Set(varShowLightbox, false); Set(varManageGroupID, Blank()); Notify("Deleted " & deletedTitle & ".", NotificationType.Error))) |
| X / Y / Width / Height | 523 / 466 / 300 / 44 |
Also add a new global variable — it needs initialising to false somewhere it's set before first use. Simplest: add Set(varConfirmDeleteGroupArmed, false); as one more line in the Screen's own OnVisible property (select the screen itself in the tree, not a control), and again at the end of each of these three existing OnSelect formulas so the confirm-state resets whenever a different group's edit lightbox opens: btnNewGroup, the group row's recRowClickCatcher, and icoRowEdit — in each, add Set(varConfirmDeleteGroupArmed, false); right before the final Set(varShowLightbox, true) line.
Same cause as the Manage People one below — Office365Users.SearchUserV2(...) was called directly in a gallery's Items, causing background flicker. Fix for the "Add people" tab's GAL search:
| Control | Property | New formula |
|---|---|---|
txtGalSearch | OnChange (new property) | =If(IsBlank(Trim(txtGalSearch.Text)), ClearCollect(colGalSearchResults, Filter(Office365Users.SearchUserV2({searchTerm: "zzzzzzzzzz", top: 1}).value, false)), ClearCollect(colGalSearchResults, Office365Users.SearchUserV2({searchTerm: txtGalSearch.Text, top: 10}).value)) |
galGalResults | Items | =colGalSearchResults |
txtMaintainerSearch | OnChange (new property) | Same shape, colMaintainerGalSearchResults, top: 5. |
galMaintainerGalResults | Items | =colMaintainerGalSearchResults |
Because Reset() doesn't fire OnChange, also add a line re-seeding the empty-result collection wherever these two search boxes get Reset()'d: icoRowManageMembers.OnSelect (after Reset(txtGalSearch), add ClearCollect(colGalSearchResults, Filter(Office365Users.SearchUserV2({searchTerm: "zzzzzzzzzz", top: 1}).value, false));), the "add from GAL" tile's tap handler (same line, after its own Reset(txtGalSearch)), btnMaintainersNav.OnSelect and btnUseMaintainerGal.OnSelect (same pattern with colMaintainerGalSearchResults). Also seed both collections once in the Screen's OnVisible, same empty-filter formula.
| Name | Icon | Visible | OnSelect | X / Y / W / H |
|---|---|---|---|---|
icoClearGroupSearch | Icon.Cancel | =!IsBlank(Trim(txtGroupSearch.Text)) | =Reset(txtGroupSearch) | 588 / 137 / 22 / 22 |
icoClearGalSearch | Icon.Cancel | =varShowLightbox && varLightboxMode = "manage" && varManageSubTab = "add" && !IsBlank(Trim(txtGalSearch.Text)) | =Reset(txtGalSearch); ClearCollect(colGalSearchResults, Filter(Office365Users.SearchUserV2({searchTerm: "zzzzzzzzzz", top: 1}).value, false)) | 571 / 220 / 22 / 22 |
icoClearMaintainerSearch | Icon.Cancel | =varShowLightbox && varLightboxMode = "adminMaintainers" && !IsBlank(Trim(txtMaintainerSearch.Text)) | =Reset(txtMaintainerSearch); ClearCollect(colMaintainerGalSearchResults, Filter(Office365Users.SearchUserV2({searchTerm: "zzzzzzzzzz", top: 1}).value, false)) | 1091 / 170 / 22 / 22 |
All three: Color: =RGBA(160,160,160,1), Tooltip: ="Clear search".
Remove Leaver group list: new Gallery inserted between the existing lblPurgeMatch label and the btnPurgeFromAll button.
| Property | Value |
|---|---|
| Name | galPurgeMatchGroups (Vertical Gallery) |
| Items | =With({p: LookUp(colPeople, txtPurgeSearch.Text in Name || txtPurgeSearch.Text in Email)}, If(IsBlank(Trim(txtPurgeSearch.Text)) || IsBlank(p), Filter(colMemberships, false), Sort(Distinct(Filter(colMemberships, Lower(Email) = Lower(p.Email)), GroupName), Value, SortOrder.Ascending))) |
| Visible | =varShowLightbox && varPeopleLightboxMode = "purge" && !IsBlank(Trim(txtPurgeSearch.Text)) && !IsBlank(LookUp(colPeople, txtPurgeSearch.Text in Name || txtPurgeSearch.Text in Email)) |
| TemplateSize / X / Y / Width / Height | 28 / 283 / 294 / 780 / 130 |
Child label lblPurgeMatchGroupRow | Text: ="• " & ThisItem.Value, size 12, colour RGBA(120,120,120,1) |
Then two small existing-control edits: shrink lblPurgeMatch's Height from 50 to 30 (its X/Y stay the same, it just no longer needs room for a second line), and move btnPurgeFromAll's Y from 320 down to 436 so it sits below the new list.
Clear (X) buttons:
| Name | Visible | OnSelect | X / Y / W / H |
|---|---|---|---|
icoClearPeopleSearch | =!IsBlank(Trim(txtPeopleSearch.Text)) | =Reset(txtPeopleSearch) | 588 / 137 / 22 / 22 |
icoClearPurgeSearch | =varShowLightbox && varPeopleLightboxMode = "purge" && !IsBlank(Trim(txtPurgeSearch.Text)) | =Reset(txtPurgeSearch); Set(varConfirmPurgeArmed, false) | 1051 / 217 / 22 / 22 |
icoClearAddPersonSearch | =varShowLightbox && varPeopleLightboxMode = "addPerson" && !IsBlank(Trim(txtAddPersonSearch.Text)) | =Reset(txtAddPersonSearch); ClearCollect(colAddPersonGalResults, Filter(Office365Users.SearchUserV2({searchTerm: "zzzzzzzzzz", top: 1}).value, false)) | 1051 / 158 / 22 / 22 |
All three: Icon.Cancel, Color: =RGBA(160,160,160,1), Tooltip: ="Clear search".
GAL search flicker fix (Add Person tab), same pattern as item 4 above:
| Control | Property | New formula |
|---|---|---|
txtAddPersonSearch | OnChange (new property) | =If(IsBlank(Trim(txtAddPersonSearch.Text)), ClearCollect(colAddPersonGalResults, Filter(Office365Users.SearchUserV2({searchTerm: "zzzzzzzzzz", top: 1}).value, false)), ClearCollect(colAddPersonGalResults, Office365Users.SearchUserV2({searchTerm: txtAddPersonSearch.Text, top: 5}).value)) |
galAddPersonGalResults | Items | =colAddPersonGalResults |
Re-seed colAddPersonGalResults (same empty-filter formula) wherever txtAddPersonSearch gets Reset()'d: btnAddPersonTrigger.OnSelect and the successful-add branch of btnAddPerson.OnSelect. Also seed it once in the Screen's OnVisible.
ClearCollect(colMemberships, 'BM Memberships'), even with the Data Row Limit already set to 2000, risks reintroducing that exact bug — flagged for a decision, not applied silently.Given the size of this change, it's applied as a full screen re-paste rather than a manual property list — see the SharePoint schema notes above (BM Memberships section) for the new Name, JMC, and County/Contingent columns and the exact import-sheet mapping.
Add the three new columns to BM Memberships in SharePoint first (Name, JMC, County/Contingent — all Single line of text, Not Required), then delete each of the three screens and paste fresh into new blank screens rather than pasting over the existing ones — Studio's paste is additive/merge, not a full replace, so a control that got renamed or removed as part of a fix can be left behind on the canvas with its old bindings still intact.
Title is kept only as a mirror so SharePoint's own list view still shows something readable; the app itself never reads Title again.Found a problem? Report it here.