# 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`) uses `MAILSHOTS_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 ```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_source` maps to a predefined table alias. * For `accounts`, the compiler must implicitly left-join account picklists so additional readable virtual fields are available: * `accounts.type` from `picklist_account_type.value` * `accounts.public_location` from `picklist_public_location.value` * `accounts.account_sector` from `picklist_sector.value` * `table_ref` maps 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. * `and` between sources means relational intersection using configured join paths (not email-only matching). * For custom sources, an explicit field equality predicate can provide join semantics. * `where` applies after source composition. * `not` negates only the next predicate/group. * Predefined predicates are `selected-renewal`, `pending-renewal`, `fen1-contact`, `fen2-contact`, and `member-or-affiliate-or-parish-council`. * `renewals` is a built-in source mapped to membership renewal rows. * `pending-renewal` applies only when source set includes `renewals` and means `renewals.status = 'pending'`. * `selected-renewal` applies only when source set includes `renewals` and means `renewals.selected = true`. * `member-or-affiliate-or-parish-council` applies to account data and means: * `accounts.type` is `Member` or `Affiliate`, or * `accounts.name` contains `Parish Council`. * `field_ref` is restricted by whitelist per built-in source. * `field_ref` is 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`, and `ends-with` require 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 in `source_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.ID` * `accounts` -> `contacts`: `accounts.ID = contacts.Accountid` * `renewals` -> `accounts`: `renewals.account_id = accounts.ID` * `accounts` -> `renewals`: `accounts.ID = renewals.account_id` * `renewals` -> `contacts`: `renewals.account_id = contacts.Accountid` * `contacts` -> `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 `accounts` must not silently join `contacts` (or any other source). * Output columns must come only from sources explicitly requested in the sentence. ## Example valid queries * `contacts` * `contacts and accounts where fen1-contact` * `contacts and accounts where fen2-contact` * `accounts and contacts where contacts.Accountid = accounts.ID` * `contacts where contacts.Last contains 'smith'` * `contacts and accounts where accounts.Type = 'Member' and contacts.FENContact1 = true` * `accounts where member-or-affiliate-or-parish-council` * `renewals where pending-renewal` * `renewals where selected-renewal` * `renewals and accounts and contacts where pending-renewal` * `accounts 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 if `accounts` is not included in source expression) * `contacts or accounts` (`or` is not supported; use `and` only) * `accounts where pending-renewal` (invalid: filter requires `renewals` source) * `accounts where fen1-contact` (invalid: filter requires `contacts` source) * `renewals where member-or-affiliate-or-parish-council` (invalid: filter requires `accounts` source) * `accounts and contacts where contacts.Accountid contains accounts.ID` (invalid: field-to-field supports only `=`/`!=`) * `accounts where accounts.Name contains members.ExcludedAccounts.ExcludedAccount` (invalid: `contains` requires a literal RHS) ## Compilation and performance requirements * Parse DSL to AST. * Validate AST against allowed sources, fields, and operators. * Validate all `and` source 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_text` is the single source of truth for execution, preview, and validation. * No SQL fallback path is permitted when `dsl_text` is missing/empty; this must be treated as an error. Optional: * normalized AST JSON for diagnostics/explain. * `sql` retained 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` * 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: * `ID` * `Name` * `DSL sentence` * `Updated at` List behavior: * single-row selection * text filter by `Name` or sentence content * sort by `Name` and `Updated at` Selection to persist between page loads. ### Editor (create/update sentence) Provide an editor panel with: * `Name` input (required) * multi-line `DSL sentence` input (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 sentence` button * parse + semantic validation against: * grammar * source/field/operator whitelist * join-path rules On validation result: * show `Valid` / `Invalid` * show `Warnings` separately 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: * `contacts` * `accounts` * `renewals` * 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-renewal` * `Renewal is pending` -> `pending-renewal` * `Contact is FEN1` -> `fen1-contact` * `Contact is FEN2` -> `fen2-contact` * `Account 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 `and` in 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: * `Apply` writes generated DSL to the editor text area * `Cancel` closes overlay with no change * `Reset` clears 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-sources` * `GET /wp-json/mailshots/v1/data-sources/{id}` * `POST /wp-json/mailshots/v1/data-sources/validate` * `POST /wp-json/mailshots/v1/data-sources` * `PUT /wp-json/mailshots/v1/data-sources/{id}` * `DELETE /wp-json/mailshots/v1/data-sources/{id}` * `POST /wp-json/mailshots/v1/data-sources/preview` * `POST /wp-json/mailshots/v1/data-sources/{id}/preview` * `GET /wp-json/mailshots/v1/data-sources/schemas` * `GET /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; * in this plugin, editor-capable operational access is the baseline: * create/update/delete/read/preview/validate all require WordPress `edit_pages` (editor or administrator); * plugin Setup/settings remains administrator-only under WordPress `manage_options` (defined in top-level policy). 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) * `name` * `dsl_text` * `updated_at` (recommended) * `dsl_text` is the only executable representation. * Persist `sql` as blank/deprecated if column still exists for backward compatibility; do not execute it. ### 2. Backend architecture Build a strict pipeline: 1. Tokenize DSL text. 2. Parse to AST. 3. Validate AST (grammar + semantics). 4. Compile AST to parameterized SQL and parameters. 5. 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_expr` and `where`. * 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` * 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. * `in(field_ref)` / `not in(field_ref)`: * compile as `EXISTS` / `NOT EXISTS` subqueries. ### 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-sources` * `GET /wp-json/mailshots/v1/data-sources/{id}` * `POST /wp-json/mailshots/v1/data-sources/validate` * `POST /wp-json/mailshots/v1/data-sources` * `PUT /wp-json/mailshots/v1/data-sources/{id}` * `DELETE /wp-json/mailshots/v1/data-sources/{id}` * `POST /wp-json/mailshots/v1/data-sources/preview` * `POST /wp-json/mailshots/v1/data-sources/{id}/preview` * `GET /wp-json/mailshots/v1/data-sources/schemas` * `GET /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 DSL` modal. 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. * `Apply` writes DSL text; `Cancel` leaves 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)` and `not 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).