Files
Dmitry VerkhoturovandGitHub 4c9ef37cf1 Move the site from eleventy to hugo (#2179)
* Move the site from eleventy to hugo

The site is built by a single static binary. No node, no package manager,
no lockfile, and the toolchain it needed is gone: eleventy, tailwind,
postcss, markdown-it and its three plugins, date-fns, prism, npm-run-all,
cross-env and html-minifier-terser.

Hugo covers most of that itself. Chroma replaces prism, goldmark replaces
markdown-it, `--minify` replaces html-minifier-terser, and fingerprinted
asset URLs replace the cache-busting `version` shortcode that stamped
`Date.now()` into every stylesheet link.

`assets/styles.css` is hand-written, since tailwind was the only reason
left to keep a package manager. The palette and the light and dark values
are custom properties at the top of the file; the minified stylesheet is
15 kB against tailwind's 46 kB, and the whole build 1.0 MB against 1.2 MB.
It was matched to the old one by comparing computed styles rather than by
eye, which is how the heading weights and line heights, the list marker
colour, and the home page heading and sign-off were caught: the last of
those had been carried by tailwind utilities written into the markup.

The `::: note` container becomes a `note` shortcode taking the emoji to
show. Its closer needs a blank line after it, because a shortcode is not
a block rule the way `markdown-it-container` was, and without one goldmark
keeps the callout inside the open paragraph. The `overflow-x` wrapper
around tables and the heading anchors are goldmark render hooks.

Syntax guessing is off. Chroma detected a systemd unit file as gdscript
and a chat transcript as mysql, and colouring a snippet as the wrong
language is worse than not colouring it. The two chroma themes are scoped
to opposite sides of the theme switch rather than layered, because they do
not declare the same properties on the same tokens: github gives Error a
background github-dark never overrides, and styles Punctuation where
github-dark leaves it alone. Layered, either leaves a light value applying
on a dark page.

`[frontmatter] lastmod` resolves through git, then front matter, then file
modification time. Without that chain `.Lastmod` falls back to `.Date`,
which is zero when a page carries no date, and every page reads
`Jan 01, 0001`. `enableGitInfo` is off because the image build context is
`site/` alone, where hugo fails hard rather than degrading;
`HUGO_ENABLEGITINFO=true` gives real per-page commit dates locally.

Three fixes fall out of the move rather than being sought:

- `/docs/` redirected nowhere. The stub was a markdown file whose
  permalink was a template expression while `markdownTemplateEngine` was
  false, so it never rendered and the URL 404'd. It is an alias now
- `/docs/contributing/` pointed at `/docs/contributing/development/`,
  which has never existed. It points at the backend page
- the 404 page was built to `/404/` and nothing served it. Hugo writes it
  to `/404.html` and reproxy is told to use it

The mobile documentation menu is a checkbox and label. `visibility: hidden`
on the checkbox, which is what the old `invisible` utility set, takes it
out of the tab order, and a label is not focusable on its own, so the menu
could not be opened from the keyboard at all. The checkbox is clipped
rather than hidden, and its label shows a focus ring.

Content is unchanged. Every code block on every page is byte-identical to
the eleventy output; the only prose difference is that two example values,
`mysite.com` and a quoted `https://demo.remark42.com`, are no longer
turned into links, goldmark's linkify being narrower than markdown-it's.

`backend/README.md` and `frontend/apps/remark42/README.md` are symlinks
into the docs tree and follow it to `site/content/`, as does the path
`release.yml` watches. `frontend/CLAUDE.md` described the site as a node
and yarn project in four places.

* Keep the heading anchors markdown-it generated

Goldmark strips punctuation markdown-it kept, so 22 headings holding a
dot, slash, apostrophe, question mark, bracket or em dash would take a new
id and any link into one from outside the repository would stop resolving.

Those headings carry their previous id as well, as an empty target emitted
ahead of the heading by the render hook, from a map of content path to old
anchor in `data/anchor_aliases.json`. The map was built by matching
heading text between the two builds rather than by position, so it
survives a heading being added or moved.

The hook rather than markdown, because goldmark's `{#id}` attribute syntax
cannot express these: it accepts dots, apostrophes and em dashes but
treats a slash, a question mark, a bracket or a percent sign as heading
text, which is 11 of the 22. The ids are stored percent-decoded, since a
browser decodes a fragment before matching, so `#children%E2%80%99s-privacy`
finds `children’s-privacy`. Verified by navigating to the awkward ones
against the built image and measuring where the page settles: each lands
112px down, which is the header offset the target carries.

Three pages carried no title, so the docs template rendered an empty `<h1>`
above the heading their markdown already had. They take their titles from
that heading text, so neither the wording nor its anchor changes, and the
template's `<h1>` carries an id. One in-page link pointed at an anchor
goldmark no longer generates.

The heading render hook emits no permalink anchor. The one it replaced was
an empty `<a href>` with `pointer-events: none`, so it could not be
clicked, and its only job was a `::before` spacer that `scroll-margin-top`
on the heading already does. Being an `<a href>` it stayed in the tab
order, so every heading was an unexplained keyboard stop: eight on the
installation page alone. Fragment navigation still lands 112px down, clear
of the fixed header.

* Harden the site image build and its CI

The architecture guard could not fire. `${TARGETARCH:-amd64}` defaulted
before the `unsupported arch` branch was reachable, so a build without
buildkit put an amd64 hugo inside an aarch64 image and ran only because
Docker Desktop emulates it. Reproduced with `--build-arg TARGETARCH=`:
`/etc/apk/arch` reported aarch64 and `hugo version` linux/amd64. An empty
value is an error now. `Dockerfile.dev` had the same defect and no smoke
step to catch it, so it would have failed at `compose up`.

The hugo tarball is verified against the release's own `checksums.txt`,
and the match is asserted present before it is used: piping grep straight
into `sha256sum -c` left the guarantee resting on what the checker does
with empty input. Busybox exits 1 there, so it did fail closed, but
nothing in the line said so. Verified against a checksums file that does
not list the tarball: the build stops before the install.

Hugo exits 0 on an empty content tree and emits a two-page shell, which
would have been copied, pushed and deployed. The build asserts the home
page and a docs page exist.

`site/**` pull requests were never built. The only building job is gated
on `github.ref == 'refs/heads/master'`, so on a pull request every job
skipped and rendered in the checks list the same way a pass does, and the
image was first built on the run that also deploys it. A `validate` job
builds it with `push: false`, needing no secrets so it works on a fork.

`.github/dependabot.yml` watched `/site` for npm packages that are gone.
That entry is a docker one, which tracks the alpine base. It does not
track the hugo pin and cannot: the docker ecosystem reads `FROM`
references, and `ARG HUGO_VERSION` is a bare string in a download URL, so
that one is a manual bump and `site/README.md` says so.

`Dockerfile.dev` carries a `COPY`, so the dev image works without the
compose bind mount, and compose runs as the invoking user rather than
root, which on linux left root-owned `public/` and `resources/` in the
checkout.

Recorded in the backlog: `master` has `required_status_checks` off with an
empty check list, so the new job surfaces a red X and does not block a
merge. That is a settings decision rather than a code fix.
2026-08-21 18:05:56 -05:00

11 KiB
Raw Permalink Blame History

title
title
Email Settings

Overview

This documentation describes how to enable the email-related capabilities of Remark.

  • email authentication for users:

    enabling this will let the user log in using their emails:

    Email authentication

  • email notifications for any users except anonymous:

    GitHub or Google or any other kind of user gets the ability to get email notifications about new replies to their comments (and any of the responses down the tree):

    Email notifications subscription

Setup email server connection

To enable any email functionality, you need to set up an email (SMTP) server connection using these variables:

SMTP_HOST
SMTP_PORT
SMTP_HELO_HOST
SMTP_TLS
SMTP_INSECURE_SKIP_VERIFY
SMTP_STARTTLS
SMTP_USERNAME
SMTP_PASSWORD
SMTP_TIMEOUT

SMTP_HELO_HOST

SMTP_HELO_HOST sets the hostname Remark42 announces in the SMTP greeting. Left unset it stays localhost, which is what Remark42 has always sent, so existing setups need no change.

Set it when the mail server rejects the greeting. Postfix with reject_non_fqdn_helo_hostname or reject_unknown_helo_hostname, which is common on hardened relays, refuses localhost and the message never leaves, typically with a 504 in the log. Give it a fully qualified name that resolves to the sending host, for example:

SMTP_HELO_HOST=comments.example.com

It applies to both paths that send mail: notification emails and the verification emails used by email authentication.

Setup email notifications

User notifications

Here is the list of variables that affect user email notifications:

NOTIFY_USERS=email
NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>
NOTIFY_EMAIL_VERIFICATION_SUBJ # "Email verification" by default

Admin notifications

Admin would receive a message for each new comment on your site. Here is the list of variables that affect them:

NOTIFY_ADMINS=email
NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>
ADMIN_SHARED_EMAIL=admin@example.com

Mailgun

Here is an example of a configuration using the Mailgun email service:

- SMTP_HOST=smtp.eu.mailgun.org
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=postmaster@mg.example.com
- SMTP_PASSWORD=secretpassword
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>

SendGrid

Here is an example of a configuration using the SendGrid email service:

- SMTP_HOST=smtp.sendgrid.net
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=apikey
- SMTP_PASSWORD=key-123456789
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>

Mailgun or SendGrid without exposing your server's IP

When you don't want to expose your IP (which is impossible with any SMTP provider) or when connecting to an external SMTP server is impossible due to firewall settings, set up an SMTP-to-API bridge and send messages through it.

To use any of the containers below within the Remark42 environment, set the following two SMTP variables:

- SMTP_HOST=mail
- SMTP_PORT=25

stevenolen/mailgun-smtp-server

Here is the docker-compose.yml configuration part spinning up a container for stevenolen/mailgun-smtp-server:

mailgun:
  image: stevenolen/mailgun-smtp-server
  container_name: "mail"
  hostname: "mail"

  logging:
    driver: json-file
    options:
      max-size: "10m"
      max-file: "5"

  environment:
    - MG_KEY=key-123456789
    - MG_DOMAIN=example.com

Please note that this Docker image is unmaintained and Europe domain names are not supported by this tool.

fgribreau/smtp-to-sendgrid-gateway

Here is the docker-compose.yml configuration part spinning up a container for fgribreau/smtp-to-sendgrid-gateway:

sendgrid:
  image: fgribreau/smtp-to-sendgrid-gateway
  container_name: "mail"
  hostname: "mail"

  logging:
    driver: json-file
    options:
      max-size: "10m"
      max-file: "5"

  environment:
    - SENDGRID_API=key-123456789

Gmail

If 2FA is enabled for your account, separate app password has to be generated. Configuration example for Gmail:

- SMTP_HOST=smtp.gmail.com
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=example.user@gmail.com
- SMTP_PASSWORD=aaaa bbbb cccc dddd
- AUTH_EMAIL_FROM="Remark42 login"<example.user@gmail.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<example.user@gmail.com>

Amazon SES

Configuration example for Amazon SES (us-east-1 region):

- SMTP_HOST=email-smtp.us-east-1.amazonaws.com
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=access_key_id
- SMTP_PASSWORD=secret_access_key
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>

You must first verify a domain or email you will use in AUTH_EMAIL_FROM or NOTIFY_EMAIL_FROM.

Then you should obtain SMTP Credentials from Amazon SES Console.

Setup email authentication

Here is the list of variables that affect email authentication:

AUTH_EMAIL_ENABLE
AUTH_EMAIL_FROM
AUTH_EMAIL_SUBJ ("remark42 confirmation" by default)
AUTH_EMAIL_CONTENT_TYPE ("text/html" by default)

After you set SMTP_ variables, you can allow email authentication by setting the first two:

- AUTH_EMAIL_ENABLE=true
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>

HTML templates for emails and error messages

Remark42 uses golang templates for email templating. Templates are located in backend/app/templates/static and embedded into binary by go:embed directive.

Now we have the following templates:

  • email_confirmation_login.html.tmpl used for confirmation of login
  • email_confirmation_subscription.html.tmpl used for confirmation of subscription
  • email_reply.html.tmpl used for sending replies to user comments (when the user subscribed to it) and for noticing admins about new comments on a site
  • email_unsubscribe.html.tmpl used for notification about successful unsubscribing from replies
  • error_response.html.tmpl used for HTML errors

To replace any template, add the file with the same name to the directory with the remark42 executable file. In case you run Remark42 inside Docker Compose, you can put customised templates into a directory like customised_templates and then mount it like that:

volumes:
  - ./var:/srv/var
  - ./customised_templates/email_confirmation_login.html.tmpl:/srv/email_confirmation_login.html.tmpl:ro
  - ./customised_templates/email_confirmation_subscription.html.tmpl:/srv/email_confirmation_subscription.html.tmpl:ro
  - ./customised_templates/email_reply.html.tmpl:/srv/email_reply.html.tmpl:ro
  - ./customised_templates/email_unsubscribe.html.tmpl:/srv/email_unsubscribe.html.tmpl:ro
  - ./customised_templates/error_response.html.tmpl:/srv/error_response.html.tmpl:ro

The easiest way to test it is to mount error_response.html.tmpl, and then head to http://127.0.0.1:8080/email/unsubscribe.html, where you are supposed to see the page like the following:

Error_template

If the file is mounted correctly, the page will render the new file content immediately after hitting the refresh button in your browser once you change the file.

Template variables

Each template has access to different variables. All templates use Go's text/template syntax.

email_reply.html.tmpl — comment notification

Used for notifying users about replies to their comments and for admin notifications about new comments.

Variable Type Description
{{.UserName}} string Comment author name
{{.UserPicture}} string Comment author avatar URL
{{.CommentText}} string Comment body (HTML)
{{.CommentLink}} string Direct link to the comment
{{.CommentDate}} time.Time Comment timestamp, format with {{.CommentDate.Format "02.01.2006 at 15:04"}}
{{.ParentUserName}} string Parent comment author name
{{.ParentUserPicture}} string Parent comment author avatar URL
{{.ParentCommentText}} string Parent comment body (HTML)
{{.ParentCommentLink}} string Direct link to parent comment
{{.ParentCommentDate}} time.Time Parent comment timestamp
{{.PostTitle}} string Title of the post
{{.Email}} string Recipient email address
{{.UnsubscribeLink}} string Unsubscribe URL
{{.ForAdmin}} bool True when this is an admin notification

email_confirmation_subscription.html.tmpl — subscription confirmation

Sent when a user subscribes to email notifications for a comment thread.

Variable Type Description
{{.User}} string Username
{{.Token}} string Confirmation token
{{.Email}} string Recipient email address
{{.Site}} string Site name
{{.SubscribeURL}} string Subscription confirmation base URL

email_confirmation_login.html.tmpl — login confirmation

Sent when a user logs in via email authentication.

Variable Type Description
{{.User}} string Username
{{.Token}} string Login token
{{.Email}} string Recipient email address
{{.Site}} string Site name
{{.Address}} string Recipient address

Plain-text emails

The default templates produce HTML emails. To send plain-text emails instead, set AUTH_EMAIL_CONTENT_TYPE=text/plain (for login confirmation) or NOTIFY_EMAIL_CONTENT_TYPE=text/plain (for notifications), and provide custom templates that output plain text instead of HTML.

Note that {{.CommentText}} and {{.ParentCommentText}} contain HTML markup. There is no built-in HTML-to-text conversion, so for comments with rich formatting the output will include raw HTML tags.

Example plain-text notification template (email_reply.html.tmpl):

New reply from {{.UserName}}{{if .PostTitle}} on "{{.PostTitle}}"{{end}}

{{.CommentText}}
{{.CommentDate.Format "02.01.2006 at 15:04"}}
{{.CommentLink}}
{{- if .ParentCommentText}}

In reply to {{.ParentUserName}}:
{{.ParentCommentText}}
{{.ParentCommentLink}}
{{- end}}

Sent to {{.Email}}
{{- if .UnsubscribeLink}}
Unsubscribe: {{.UnsubscribeLink}}
{{- end}}