22 KiB
Mailshot Data Source page
Purpose
Document requirements for a safe, non-admin-friendly mailshot data source definition model.
Environment baseline for this specification
members as a database name means the value of MEMBERS_REMOTE_MYSQL_DB.
For this specification:
- Data-source definition persistence (create/update/delete/list of
mailshot_queries) usesMAILSHOTS_REMOTE_MYSQL_DB. - Source-data query execution (built-in/custom recipient sources) uses
MEMBERS_REMOTE_MYSQL_DB.
Problem with prior approach
Creating a new data source currently requires:
- knowledge of MySQL schema details, or
- admin-level access to create DB views/queries.
This blocks non-expert and non-admin users.
Direction
Use a strict, constrained query language (DSL) for mailshot audience selection.
The DSL text is stored as the data-source definition in MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries, then parsed and compiled into safe SQL executed against approved source-data tables in MEMBERS_REMOTE_MYSQL_DB.
Goals
- Allow non-admin users to define useful audiences.
- Prevent arbitrary SQL.
- Keep execution efficient (push filtering to SQL, avoid large unnecessary joins).
- Make queries explainable and previewable.
Example requirements (in natural language)
- All contacts with name like "smith".
- All contacts joined to member accounts where contact FEN1 is true.
- All renewal rows marked as selected.
- All pending renewals.
- All accounts where name is not in an exclusion table.
Strict grammar (v1.5)
Use lowercase keywords only.
EBNF
query = source_expr , [ where_clause ] ;
source_expr = source_term , { source_op , source_term } ;
source_term = source_ref | "(" , source_expr , ")" ;
source_op = "and" ;
source_ref = built_in_source | table_ref ;
built_in_source = "contacts" | "accounts" | "renewals" ;
table_ref = ident_part , "." , ident_part ;
ident_part = identifier | quoted_identifier ;
where_clause = "where" , predicate_expr ;
predicate_expr = predicate_term , { bool_op , predicate_term } ;
predicate_term = [ "not" ] , predicate_atom
| "(" , predicate_expr , ")" ;
bool_op = "and" ;
predicate_atom = filter_expr
| field_ref , compare_op , value
| field_ref , ("=" | "!=") , field_ref
| field_ref , [ "not" ] , "in" , "(" , field_ref , ")"
| field_ref , "in" , "(" , value_list , ")" ;
filter_expr = filter_name
| filter_name , "(" , value_list , ")" ;
filter_name = "selected-renewal"
| "pending-renewal"
| "fen1-contact"
| "fen2-contact"
| "member-or-affiliate-or-parish-council" ;
field_ref = source_ref , "." , field_name ;
field_name = identifier | quoted_identifier ;
compare_op = "=" | "!=" | "contains" | "starts-with" | "ends-with" ;
value_list = value , { "," , value } ;
value = quoted_string | number | "true" | "false" ;
identifier = letter , { letter | digit | "_" } ;
quoted_identifier = "`" , { character - "`" } , "`" ;
quoted_string = "'" , { character - "'" } , "'" ;
number = digit , { digit } ;
letter = "a"…"z" | "A"…"Z" ;
digit = "0"…"9" ;
Semantics (v1.5)
built_in_sourcemaps to a predefined table alias.table_refmaps directly to a table in the remote members database (schema.table).- For
table_ref, validation must confirm the table exists. - Query result shape must be source-faithful and deterministic per DSL:
- each cited source contributes its full allowed field set to an expected-field contract;
- preview and template-token resolution must preserve availability of every field in that contract, even when raw SQL row keys are sparse;
- source-native columns from query output may also be present;
- no unrelated synthetic recipient projection columns are allowed.
- If the resolved source does not expose an email field, validation must return a warning:
Data Source does not have an email field, it cannot be used for a Mailshot- The sentence is still valid and can be saved and previewed.
andbetween sources means relational intersection using configured join paths (not email-only matching).- For custom sources, an explicit field equality predicate can provide join semantics.
whereapplies after source composition.notnegates only the next predicate/group.- Predefined predicates are
selected-renewal,pending-renewal,fen1-contact,fen2-contact, andmember-or-affiliate-or-parish-council. renewalsis a built-in source mapped to membership renewal rows.pending-renewalapplies only when source set includesrenewalsand meansrenewals.status = 'pending'.selected-renewalapplies only when source set includesrenewalsand meansrenewals.selected = true.member-or-affiliate-or-parish-councilapplies to account data and means:accounts.typeisMemberorAffiliate, oraccounts.namecontainsParish Council.
field_refis restricted by whitelist per built-in source.field_refis also supported for selected custom sources when table metadata is available.- Field-to-field comparisons are supported with
=and!=only. - Set-membership against another field source is supported via:
field_ref in (field_ref)field_ref not in (field_ref)
- For
in(field_ref)/not in(field_ref), the RHS source is treated as a reference source (subquery semantics), not a joined source. contains,starts-with, andends-withrequire a literal right-hand side value (not a field reference).- In v1.5, mixing built-in sources and custom sources in the same sentence is not supported; validation must fail with a clear error.
- For multi-custom-source sentences, all selected custom sources must be connected by explicit
=field-to-field predicates (graph-connected join semantics), otherwise validation fails. in(field_ref)/not in(field_ref)must compile as subquery membership; the RHS source is a reference source and does not need to appear insource_expr.- Canonical naming in this document uses exact field names as defined in database metadata for the selected source. The implementation must reject invalid names and must not add aliasing/normalization fallback.
Join-path model (required)
The compiler must use an explicit join graph per source pair. Example v1 join paths:
contacts->accounts:contacts.Accountid = accounts.IDaccounts->contacts:accounts.ID = contacts.Accountidrenewals->accounts:renewals.account_id = accounts.IDaccounts->renewals:accounts.ID = renewals.account_idrenewals->contacts:renewals.account_id = contacts.Accountidcontacts->renewals:contacts.Accountid = renewals.account_id
If no approved join path exists between two sources for and, parsing/validation must fail with a clear error.
No implicit join behavior is allowed:
- Selecting a source such as
accountsmust not silently joincontacts(or any other source). - Output columns must come only from sources explicitly requested in the sentence.
Example valid queries
contactscontacts and accounts where fen1-contactcontacts and accounts where fen2-contactaccounts and contacts where contacts.Accountid = accounts.IDcontacts where contacts.Last contains 'smith'contacts and accounts where accounts.Type = 'Member' and contacts.FENContact1 = trueaccounts where member-or-affiliate-or-parish-councilrenewals where pending-renewalrenewals where selected-renewalrenewals and accounts and contacts where pending-renewalaccounts where accounts.Name not in (members.ExcludedAccounts.ExcludedAccount)members.mailshot_test
Example invalid queries
select * from members(raw SQL not allowed)contacts where drop table(unknown tokens)contacts where accounts.Name = 'x'(invalid ifaccountsis not included in source expression)contacts or accounts(oris not supported; useandonly)accounts where pending-renewal(invalid: filter requiresrenewalssource)accounts where fen1-contact(invalid: filter requirescontactssource)renewals where member-or-affiliate-or-parish-council(invalid: filter requiresaccountssource)accounts and contacts where contacts.Accountid contains accounts.ID(invalid: field-to-field supports only=/!=)accounts where accounts.Name contains members.ExcludedAccounts.ExcludedAccount(invalid:containsrequires a literal RHS)
Compilation and performance requirements
- Parse DSL to AST.
- Validate AST against allowed sources, fields, and operators.
- Validate all
andsource combinations against approved join graph metadata. - Do not perform implicit source joins during compilation.
- Compile AST to parameterized SQL only (no string-concatenated SQL).
- Push filters into SQL
WHERE/ join conditions. - Preview must not assume fixed contact/account projection columns.
- Compiler/runtime must enforce deterministic source-field availability from DSL-cited sources.
- Preview column derivation must include:
- all fields from the DSL expected-field contract, and
- union of keys from returned rows (not just first row keys), to avoid hiding sparse/source-native columns.
- For mailshot execution, email remains the only required field.
- Provide preview endpoints: count + sample rows before execution.
Storage model
Store:
dsl_text(editable source).dsl_textis the single source of truth for execution, preview, and validation.- No SQL fallback path is permitted when
dsl_textis missing/empty; this must be treated as an error.
Optional:
- normalized AST JSON for diagnostics/explain.
sqlretained as blank/deprecated for backward compatibility when the legacy column exists, and never executed.
User interface requirements
Provide a UI to view, create, update, validate, and preview DSL sentences.
Mailshot data source page
Add a dedicated page "Data Sources" under FECA Mailshots admin page for managing data source sentences.
Top regions:
- Context pane:
- page title
Mailshot Data Sources
- page title
- Information pane:
- validation errors, save status, preview status
- Statistics pane:
- total saved data sources
- selected data source id/name
Data source list (view)
Provide a table/list showing existing data sources with columns:
IDNameDSL sentenceUpdated at
List behavior:
- single-row selection
- text filter by
Nameor sentence content - sort by
NameandUpdated at
Selection to persist between page loads.
Editor (create/update sentence)
Provide an editor panel with:
Nameinput (required)- multi-line
DSL sentenceinput (required) - read-only
Last validated status
Actions:
New:- clear editor and start create mode
Save:- create or update current record
- enabled only when form is valid and has unsaved changes
Discard changes:- revert editor to last saved state
Delete:- confirmation required
- blocked when the data source is referenced by one or more mailshots
Validation UX
Provide explicit validation before save and on-demand:
Validate sentencebutton- parse + semantic validation against:
- grammar
- source/field/operator whitelist
- join-path rules
On validation result:
- show
Valid/Invalid - show
Warningsseparately from errors - for invalid:
- show user-friendly error message
- include line/column when available
- do not save invalid sentence
- for warning-only cases (for example missing email field):
- allow save
- allow preview
- indicate the data source cannot be used for sending a mailshot
Preview UX
Provide Preview recipients action for selected sentence:
- show recipient count
- show sample rows (for example first 50)
- derive displayed columns from the deterministic expected-field contract plus returned row shape
- do not assume fixed contact/account projection columns
- show execution time
- do not execute/send mailshot from this page
DSL Sentence Builder (required)
Provide a Build DSL action in the data-source editor.
On click, open a modal/overlay builder that allows users to construct full valid DSL without memorising grammar keywords, filter names, or field names.
Builder layout:
- Top sub-pane:
Data Sources - Bottom sub-pane:
Constraints / Filters - Footer: generated DSL preview + actions
Top sub-pane: Data Sources
Provide source selection controls for:
contactsaccountsrenewals- custom table source (schema/table selector or validated text input)
Behavior:
- multiple source selection is combined using
and - order of selected sources is preserved in generated sentence
- selecting an invalid source combination is blocked in UI with clear message
Bottom sub-pane: Constraints / Filters
Provide controls to add one or more constraint rows.
Each row supports one of:
- predefined filter
- field comparison
- grouped expression with
not
Predefined filter control:
- dropdown label uses user-friendly text, not raw DSL token
- examples:
Renewal is selected->selected-renewalRenewal is pending->pending-renewalContact is FEN1->fen1-contactContact is FEN2->fen2-contactAccount is member/affiliate/parish council->member-or-affiliate-or-parish-council
Field comparison row:
- source dropdown (restricted to selected sources)
- field dropdown (restricted to fields allowed for selected source)
- operator dropdown (
=,!=,contains,starts-with,ends-with,in) - RHS mode dropdown:
- literal value input (typed by expected value type), or
- source+field selector for field-to-field comparisons and set-membership (
in)
- for
in, provide multi-value token input
Logical composition:
- all rows combine with
andin v1.5 - each row can be negated via checkbox (
not) - optional group rows allow nested bracketed expressions
Generated DSL + Sync
Footer must show:
- read-only generated DSL sentence (live update)
- validation status
- parse/semantic errors with line/column mapped to relevant builder controls
Actions:
Applywrites generated DSL to the editor text areaCancelcloses overlay with no changeResetclears builder selections
Sync rules:
- opening builder from existing DSL pre-populates controls when sentence is parseable
- if existing DSL is not parseable, show message and allow user to start from clean builder
- direct text edits remain supported; builder is not the only editing path
Safety and usability requirements
- UI must prevent creating syntactically invalid DSL states
- UI must prevent selecting filters incompatible with current source set
- custom table sources must be validated before Apply
- generated DSL must always conform to strict grammar in this spec
API expectations for UI
The UI expects backend endpoints for:
- list data sources
- get one data source
- validate sentence
- create data source
- update data source
- delete data source
- preview sentence (count + sample)
- list schemas for custom source selection
- list tables for selected schema
- list fields for selected source (built-in or custom)
WordPress REST route contract:
GET /wp-json/mailshots/v1/data-sourcesGET /wp-json/mailshots/v1/data-sources/{id}POST /wp-json/mailshots/v1/data-sources/validatePOST /wp-json/mailshots/v1/data-sourcesPUT /wp-json/mailshots/v1/data-sources/{id}DELETE /wp-json/mailshots/v1/data-sources/{id}POST /wp-json/mailshots/v1/data-sources/previewPOST /wp-json/mailshots/v1/data-sources/{id}/previewGET /wp-json/mailshots/v1/data-sources/schemasGET /wp-json/mailshots/v1/data-sources/tables?schema=...GET /wp-json/mailshots/v1/data-sources/source-fields?source=...
Auth/capability requirements:
- all routes require authenticated WordPress users;
- create/update/delete routes require a mailshot management capability (for example
manage_mailshots); - read/preview/validate routes require a mailshot read capability (for example
read_mailshots).
All endpoints must enforce server-side validation even if client validates first.
Additional functional requirements (in scope)
- Mailshot Data Source preview, Mailshot Recipients Preview, and Mailshot Test preview must all display source-faithful columns using the same result-shape rule.
- DSL Builder must support custom sources via schema + table selection (not text-only entry), with manual text fallback only when metadata APIs are unavailable.
- After a custom source is added in Builder, it must be immediately available in:
- source dropdowns,
- LHS field dropdowns,
- RHS field dropdowns (for field comparisons).
- Builder must support
field not in (other_source.other_field)generation using RHS source + field controls (not value-type literal controls). - Builder prefill must parse and restore field references where source names are qualified (for example
members.ExcludedAccounts.ExcludedAccount). - Validation errors must be explicit for unsupported/ineffective predicates; no silent no-op predicate behavior.
Implementation details
This section is normative guidance for regenerating the feature from scratch.
1. Data model
- Table:
MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries - Required columns:
ID(PK)namedsl_textupdated_at(recommended)
dsl_textis the only executable representation.- Persist
sqlas blank/deprecated if column still exists for backward compatibility; do not execute it.
2. Backend architecture
Build a strict pipeline:
- Tokenize DSL text.
- Parse to AST.
- Validate AST (grammar + semantics).
- Compile AST to parameterized SQL and parameters.
- Execute SQL for preview/send paths.
Keep parser/validator/compiler in a dedicated module (for example mailshotDsl.js), and route handlers thin.
3. Parser/AST contract
- AST should separate
source_exprandwhere. - Predicates should distinguish:
- named filters,
- compare literal,
- compare field-to-field,
in(value_list),in(field_ref)/not in(field_ref).
- Preserve token line/column for diagnostics.
4. Validation rules (server-side)
- Validate source compatibility via explicit join-path metadata.
- Validate filter applicability against selected sources.
- Validate field refs against per-source field whitelist:
- built-ins: static map
- custom: metadata-provider abstraction (not direct SQL dependency in validator logic)
- Enforce:
- field-to-field only
=/!= - literal RHS only for
contains/starts-with/ends-with
- field-to-field only
- Return warnings (not errors) for no-email-source sentences.
- Metadata-provider minimum DB grants must be documented and kept minimal for the read-only DB user.
5. SQL compilation strategy
Use parameterized SQL only.
- Built-in-only source expressions:
- compile from fixed source metadata (table + alias + base where).
- compile approved joins only.
- Custom-only source expressions:
- single source:
SELECT * FROM schema.table WHERE ... - multi source: require explicit
=field join connectivity and compile with aliased joins.
- single source:
in(field_ref)/not in(field_ref):- compile as
EXISTS/NOT EXISTSsubqueries.
- compile as
6. Preview/result-shape rules
- Normalize row values to JSON-safe scalars.
- Enforce expected-field contract from DSL-cited sources.
- Keep source-native returned columns available.
- It is allowed to expose normalized source-field aliases required to satisfy the expected-field contract.
- Do not add unrelated synthetic recipient projection columns.
- Derive display columns from expected-field contract plus union of keys in returned rows.
- Prune-all-empty columns only if explicitly configured and documented; default should preserve source-faithful shape.
7. API surface
Implement at minimum:
GET /wp-json/mailshots/v1/data-sourcesGET /wp-json/mailshots/v1/data-sources/{id}POST /wp-json/mailshots/v1/data-sources/validatePOST /wp-json/mailshots/v1/data-sourcesPUT /wp-json/mailshots/v1/data-sources/{id}DELETE /wp-json/mailshots/v1/data-sources/{id}POST /wp-json/mailshots/v1/data-sources/previewPOST /wp-json/mailshots/v1/data-sources/{id}/previewGET /wp-json/mailshots/v1/data-sources/schemasGET /wp-json/mailshots/v1/data-sources/tables?schema=...GET /wp-json/mailshots/v1/data-sources/source-fields?source=...
All endpoints must validate server-side and return structured errors:
{ ok:false, error, line?, column? }.
8. UI generation guidance
Mailshot Data Source page:
- left: data source list with select/filter/sort.
- right: editor + validation + preview.
- include
Build DSLmodal.
Builder modal:
- source selection section (built-in + schema/table custom add).
- constraints section with row editor (filter/comparison).
- responsive row layout that wraps controls on narrow widths.
- RHS field mode must expose source + field selectors.
- show generated DSL live; validate with debounce.
Applywrites DSL text;Cancelleaves editor unchanged.
9. Safety and regression test guidance
Minimum automated tests:
- parser positive/negative cases (line/column assertions).
- semantic validation failures (join path, filter applicability, field whitelist).
- compile output shape for:
- built-in joins,
- custom joins,
in(field_ref)andnot in(field_ref).
- preview shape:
- includes all expected fields from cited sources, and
- does not introduce unrelated synthetic recipient projection columns.
- builder prefill for qualified source field refs.
- no-op predicate prevention (must error, not silently ignore).