<!--
	covers: mail components/Mailer.php components/Sitemap.php components/View.php helpers/JsonLdHelper.php config/components/mailer.php config/components/sitemap.php
	verified: 54fd0a6
-->
# Mail, SEO and the sitemap

## Mail

[`app\components\Mailer`](../../components/Mailer.php) extends `yii\swiftmailer\Mailer` with one method:

```php
$mailer->autoConfigure( $subject, $toName, $toEmail, $view, $params )
```

It composes the view, sets the subject, sets the recipient, and sets the sender to
`p( 'email' )` (the `Settings` record's e-mail) named `t( 'metaSiteName' )`. It also pushes `toName` into
`$this->view->params` so the layout can greet by name. It returns the message — **the caller still has to call
`->send()`**.

Only `mailer` exists. A `bulkMailer` was advertised in both `Application` subclasses' `@property` lists
without ever being configured; the annotations were removed on 2026-08-08.

### Mail views

[`mail/`](../../mail): `activate.php`, `callback-request.php`, `contact.php`, `email-change.php`,
`offer.php`, `password-reminder.php`, with a single layout at `mail/layouts/html.php`.

Mail views run through `app\components\View` with `$mail = true`, which:

- skips the whole `head()` machinery (no meta tags, no Encore assets);
- still runs `applyReplaces()`, so `{email}`, `{phone}`, `{address}` and the `Settings` placeholders work in
  e-mail copy;
- skips the JSON-LD injection.

Locally every message is caught by MailHog on `raktar24.dev.hu:33001`.

### Error mail

Configured through `LOG_EMAIL_ENABLED`, `LOG_EMAIL_SENDER`, `LOG_EMAIL_SUBJECT`, `LOG_EMAIL_RECIPIENTS`.
`LOG_EXCEPT` suppresses `yii\web\HttpException:404` and `:400` — **404s never reach the log or the error
mail.** The only way to find broken links is to crawl.

## Meta tags

Emitted by [`View::head()`](../../components/View.php). See
[REQUEST-LIFECYCLE.md](REQUEST-LIFECYCLE.md#5-view-and-head) for the ordering and the `getMeta()` fallback
chain.

Worth restating:

- **`robots`** — `noindex` when inside the admin or `$controller->noindex`; `nofollow` from
  `$controller->nofollow`. The tag is omitted entirely when neither applies.
- **`canonical`** — only emitted when `$controller->canonical` is set **and** differs from the current URL.
- **hreflang** — one `alternate` per language plus `x-default` → the Hungarian URL, and only when the
  controller has `getAlternateUrls()`.
- **OpenGraph and Twitter cards** — frontend only, never in the admin.
- The default OG image is `/images/og.png` (`View::getImage()`).
- **`description` is read off the displayed model.** `getMeta( 'description' )` prefers
  `$controller->model->description` over the `meta*` message key, so giving a model a `description` attribute
  silently changes the meta description of its page — that is how `Location.description` became one.
- **HTML is stripped with a space in front of every tag**, so `…3.</p><p>A telephelyen…` does not become
  `3.A telephelyen`. Nothing truncates: a long editorial description produces a long meta description.

## JSON-LD

[`JsonLdHelper`](../../helpers/JsonLdHelper.php), a singleton. `View::head()` echoes the literal marker
`{__JSON_LD_PLACEHOLDER__}`, and `View::endPage()` replaces it with the generated
`<script type="application/ld+json">`.

The payload is a `WebPage` carrying `name`, `description`, `image`, `url`, plus `breadcrumb` and
`mainEntity` when available. It is cached with `getOrSet` keyed on `md5( Yii::$app->request->url )`.

`getMainEntity()` builds a `Thing` from the controller's `model`, and upgrades it to `NewsArticle` for
`Article`. Empty properties are stripped before encoding.

There is **no `Product` entity**. A dead `Product` / `Opinion` branch inherited from the codebase this one was
forked from was removed on 2026-08-08 — neither class exists here. Storages are emitted as `Thing`; upgrading
them to `Product` would need offer and rating data that the domain does not currently carry (`Review` is a
site-wide testimonial with no rating and no storage link).

## robots.txt

Generated by [`SiteController::actionRobots()`](../../controllers/SiteController.php):

```php
$allowOrDisallow = APP_URL === 'https://raktar24.hu/' ? 'Allow' : 'Disallow';
```

An **exact string match**, trailing slash included. Anything else — staging, dev, a mistyped `APP_URL`, a
`www.` variant — is `Disallow: /`. The file also points at `sitemap/sitemap.xml`.

## Sitemap

[`app\components\Sitemap`](../../components/Sitemap.php), reached at `sitemap.xml` and `sitemap/<lang>.xml`
([`SiteController::actionSitemap()`](../../controllers/SiteController.php)), cached 24 h under the key
`[ 'sitemap', $lang ]`.

**Generated on request. There is no sitemap console command** — earlier documentation claimed a
`feed/sitemap` command, which has never existed.

- `getIndex()` — a `<sitemapindex>` listing one entry per language.
- `getByLanguage( $lang )` — sets `Yii::$app->language`, then emits, in order: home, contact, FAQ, tips,
  locations index + every published `Location`, the address navigation items, services index + every published
  `Service`, storages index + every published `Storage`, storage-types index, every published `Content`,
  articles index + every published `Article`.

`addRecords()` attaches `<image:image>` entries: every `photos[].picture->absoluteUrl`, or the record's own
`picture` when it has no photo gallery. `<lastmod>` comes from `updatedAt`, falling back to `createdAt`,
falling back to today.

### Traps

1. **The sitemap is never persisted to disk.** A vestigial `web/sitemap/` directory existed until
   2026-08-08; nothing ever wrote to it and it has been removed.
2. **`getByLanguage()` mutates `Yii::$app->language` and does not restore it.** It runs inside a cache
   closure at the end of the request, so nothing currently notices — but calling it mid-request would leak
   the language.
3. **Every published record is loaded into memory** with its photos eager-loaded. The sitemap is the heaviest
   single page in the application; the 24-hour cache is doing real work.
4. **The sitemap ignores `noindex`.** A page excluded from indexing by the controller flag is still listed.
