Skip to content

Pass an explicit context to contact attributes and message e-mails - #939

Merged
KrzysztofPajak merged 4 commits into
developfrom
refactor/business-explicit-context-marketing
Oct 17, 2026
Merged

KrzysztofPajak merged 4 commits into
developfrom
refactor/business-explicit-context-marketing

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Part of the explicit-context work in Business (after #930, #931, #932, #936, #937, #938)
Type: feature

Issue

Four services in Grand.Business.Marketing and Grand.Business.Messages still read the current request through IContextAccessor. Outside a storefront request (admin panel for another store, API, scheduled tasks) they used the wrong customer, store or host:

  • MessageProviderService built every link in e-mails (password recovery, order details, product pages…) from the host of the current request. An e-mail sent from the admin panel about an order of another store linked to the admin's host. A background task used whatever store the empty context fell back to.
  • ContactAttributeService.GetAllContactAttributes filtered by the groups of the current customer, but cached the result per store only. The first visitor's attribute list was served to everyone, so group-limited contact attributes leaked to guests, or disappeared for the customers allowed to see them.
  • ContactAttributeParser.GetParseContactAttributes loaded the attributes without a store, so attributes limited to other stores were accepted from the contact form. FormatAttributes took the file upload link host from the request.
  • CustomerCoordinatesService had overloads that read the current customer, and GetGeoCoordinate threw a NullReferenceException for a customer without coordinates.

Solution

  • E-mail links. MessageProviderService no longer depends on IContextAccessor. The 25 Send* methods a storefront request can trigger take an optional DomainHost host, which the storefront passes (OrderNotificationCommand.Host for order placement). The new Store.LinkHost(host) uses that host only when it is one of the store's domains; any other or missing host (admin panel, API, scheduled tasks, a forged Host header) falls back to Store.PrimaryHost(): the primary domain, then the first domain, then Store.Url.
  • Contact attributes. GetAllContactAttributes(CatalogAccess access), the same shape as GetAllCheckoutAttributes. The cache key holds the store, ShowHidden and the customer's group ids. The storefront passes CatalogAccess.For(customer, store), the admin panel CatalogAccess.IncludingHidden(staffStoreId).
  • Contact parser. GetParseContactAttributes(model, access) and FormatAttributes(language, attributes, host, …) (the unused customer is gone). The contact form passes Store.LinkHost(request host); without a host the file upload is named by its download id instead of a link. ContactUsSendCommand carries the customer, store, language and host, so its handler no longer reads the context.
  • Coordinates. The parameterless GetGeoCoordinate() and SaveGeoCoordinate(longitude, latitude) are removed (nothing in the repository called them). A customer without coordinates gets (0, 0).

After this PR, grep -rln -E 'IContextAccessor|IWorkContext\b' src/Business --include=*.cs | grep -v Startup returns only TranslationService and PermissionService, the remainder documented in #937.

Behavior change: messages sent from the admin panel, the API or a background task link to the primary domain of the message's store, not to the host the admin happens to use. Storefront messages keep linking to the domain of the visit.

Breaking changes

  • IContactAttributeService.GetAllContactAttributes(CatalogAccess access) replaces (string storeId = "", bool ignoreAcl = false).
  • IContactAttributeParser.GetParseContactAttributes(model, CatalogAccess access); FormatAttributes(language, customAttributes, DomainHost host, separator, htmlEncode, allowHyperlinks) (no customer).
  • IMessageProviderService: 25 Send* methods take a trailing optional DomainHost host = null (source compatible, binary breaking). OrderNotificationCommand.Host.
  • ICustomerCoordinatesService: GetGeoCoordinate() and SaveGeoCoordinate(double, double) removed.
  • Constructors: MessageProviderService, ContactAttributeService, ContactAttributeParser and CustomerCoordinatesService no longer take IContextAccessor.
  • CacheKey.CONTACTATTRIBUTES_ALL_KEY has three parameters (store, show hidden, group ids).

Testing

Automated: the solution builds, and dotnet test GrandNode.slnx passes (22 projects). New or changed tests cover:

  • customers in different groups getting their own contact attributes (the cache no longer shares them);
  • IncludingHidden returning group-limited attributes, and a store id leaving out attributes of other stores;
  • the file upload link pointing at the given host, and no link without a host;
  • GetParseContactAttributes reading the attributes of the given access;
  • GetGeoCoordinate for a customer without coordinates;
  • e-mail links using the primary domain of the store, and Store.Url for a store without domains.

Manual: run on 2026-10-17 against a fresh install with sample data (port 44480, curl and Playwright). The store has two domains: localhost:44480 (primary) and 127.0.0.1:44480. Contact attributes: "Attachment" (file upload), "Member topic" (limited to Registered) and "Other store field" (limited to another store). E-mails were read from the QueuedEmail collection.

# Scenario Result
A Guest sends the contact form on 127.0.0.1 with a file, and posts "Member topic" and "Other store field" by hand File link https://127.0.0.1:44480/download/getfileupload/?downloadId=…; the two forged fields are not in the e-mail
B Same, with a Host: evil.example:44480 header (not a store domain) File link falls back to https://localhost:44480/…
C Guest opens the form (cache warm), then a registered customer, then a guest again Guest: Attachment only. Registered: Attachment + Member topic, and "Member topic" reaches the e-mail. Guest again: Attachment only
D Password recovery from 127.0.0.1, from localhost, and with the forged Host header https://127.0.0.1:44480/passwordrecovery/confirm…, https://localhost:44480/…, https://localhost:44480/… (no host injection)
E Admin panel opened on 127.0.0.1: re-send activation e-mail; contact attribute list Activation link https://localhost:44480/account/activation… (primary domain); the list shows all three attributes
F Registered customer places an order on 127.0.0.1 (Cash On Delivery) Order confirmed; "Order #1 confirmed" links to https://127.0.0.1:44480/orderdetails/…

{{Store.URL}} always prints Store.Url, unchanged by this PR. The server log showed no errors other than e-mail sending (no SMTP server locally).

support added 2 commits October 17, 2026 17:16
MessageProviderService took the link host from the current request, so a message sent from the admin panel for another store, or from a background task, linked to the wrong place. It now uses the primary domain of the message's store and no longer reads IContextAccessor.
GetAllContactAttributes takes a CatalogAccess and keys its cache by the customer's groups, so one visitor's attributes are no longer served to everyone. The contact parser takes the access and the host for file links; the parameterless coordinate overloads are gone, and a customer without coordinates no longer throws.
Copilot AI balanced review requested due to automatic review settings October 17, 2026 15:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Store.PrimaryHost() replaces the private StoreHost helper in MessageProviderService and tolerates a store saved without domains. The contact form and order placement now use it too, so file upload links in a contact e-mail and in saved order attribute descriptions point to the same host as the rest of the message. Without a host, a contact file upload is named with its download id instead of being dropped. MessageProviderService resolves the host once per message, and IContactAttributeParser.FormatAttributes no longer takes the unused customer.

emailAccount = await GetEmailAccountOfMessageTemplate(messageTemplate, language.Id);
builder.AddProductTokens(product, language, store, CurrentHost);
builder.AddProductTokens(product, language, store, store.PrimaryHost());
Messages sent from a storefront request now receive the current host, and
their links use it when it is one of the store's domains; any other or
missing host falls back to the store's primary domain.
@KrzysztofPajak
KrzysztofPajak merged commit 2f129af into develop Oct 17, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the refactor/business-explicit-context-marketing branch October 17, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants