Full Code of knadh/listmonk for AI

master c60ea79582ec cached
427 files
6.8 MB
1.8M tokens
1091 symbols
1 requests
Download .txt
Showing preview only (7,190K chars total). Download the full file or copy to clipboard to get everything.
Repository: knadh/listmonk
Branch: master
Commit: c60ea79582ec
Files: 427
Total size: 6.8 MB

Directory structure:
gitextract_pz3pib5l/

├── .devcontainer/
│   └── devcontainer.json
├── .dockerignore
├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── confirmed-bug.md
│   │   ├── feature-or-change-request.md
│   │   ├── general-question.md
│   │   └── possible-bug--needs-investigation-.md
│   └── workflows/
│       ├── build-sanity.yml
│       ├── github-pages.yml
│       ├── hodor-review.yml
│       ├── issues.yml
│       ├── nightly.yml
│       └── release.yml
├── .gitignore
├── .go-version
├── .goreleaser-nightly.yml
├── .goreleaser.yml
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── VERSION
├── cmd/
│   ├── admin.go
│   ├── archive.go
│   ├── auth.go
│   ├── bounce.go
│   ├── campaigns.go
│   ├── events.go
│   ├── handlers.go
│   ├── i18n.go
│   ├── import.go
│   ├── init.go
│   ├── install.go
│   ├── lists.go
│   ├── main.go
│   ├── maintenance.go
│   ├── manager_store.go
│   ├── media.go
│   ├── public.go
│   ├── roles.go
│   ├── settings.go
│   ├── subscribers.go
│   ├── templates.go
│   ├── tx.go
│   ├── updates.go
│   ├── upgrade.go
│   ├── users.go
│   └── utils.go
├── config.toml.sample
├── dev/
│   ├── .gitignore
│   ├── README.md
│   ├── app.Dockerfile
│   ├── config.toml
│   └── docker-compose.yml
├── docker-compose.yml
├── docker-entrypoint.sh
├── docs/
│   ├── README.md
│   ├── docs/
│   │   ├── content/
│   │   │   ├── apis/
│   │   │   │   ├── apis.md
│   │   │   │   ├── bounces.md
│   │   │   │   ├── campaigns.md
│   │   │   │   ├── import.md
│   │   │   │   ├── lists.md
│   │   │   │   ├── media.md
│   │   │   │   ├── sdks.md
│   │   │   │   ├── subscribers.md
│   │   │   │   ├── templates.md
│   │   │   │   └── transactional.md
│   │   │   ├── archives.md
│   │   │   ├── bounces.md
│   │   │   ├── concepts.md
│   │   │   ├── configuration.md
│   │   │   ├── developer-setup.md
│   │   │   ├── external-integration.md
│   │   │   ├── i18n.md
│   │   │   ├── index.md
│   │   │   ├── installation.md
│   │   │   ├── maintenance/
│   │   │   │   └── performance.md
│   │   │   ├── messengers.md
│   │   │   ├── oidc.md
│   │   │   ├── querying-and-segmentation.md
│   │   │   ├── roles-and-permissions.md
│   │   │   ├── security-reports.md
│   │   │   ├── static/
│   │   │   │   └── style.css
│   │   │   ├── templating.md
│   │   │   └── upgrade.md
│   │   ├── mkdocs.yml
│   │   └── requirements.txt
│   ├── i18n/
│   │   ├── index.html
│   │   ├── main.js
│   │   └── style.css
│   ├── site/
│   │   ├── content/
│   │   │   └── .gitignore
│   │   ├── data/
│   │   │   └── github.json
│   │   ├── layouts/
│   │   │   ├── index.html
│   │   │   ├── page/
│   │   │   │   └── single.html
│   │   │   ├── partials/
│   │   │   │   ├── footer.html
│   │   │   │   └── header.html
│   │   │   └── shortcodes/
│   │   │       ├── centered.html
│   │   │       ├── github.html
│   │   │       ├── half.html
│   │   │       └── section.html
│   │   └── static/
│   │       └── static/
│   │           ├── base.css
│   │           └── style.css
│   └── swagger/
│       └── collections.yaml
├── frontend/
│   ├── .browserslistrc
│   ├── .editorconfig
│   ├── .eslintrc.js
│   ├── .gitignore
│   ├── README.md
│   ├── babel.config.js
│   ├── cypress/
│   │   ├── e2e/
│   │   │   ├── archive.cy.js
│   │   │   ├── bounces.cy.js
│   │   │   ├── campaigns.cy.js
│   │   │   ├── dashboard.cy.js
│   │   │   ├── forms.cy.js
│   │   │   ├── import.cy.js
│   │   │   ├── lists.cy.js
│   │   │   ├── settings.cy.js
│   │   │   ├── subscribers.cy.js
│   │   │   ├── templates.cy.js
│   │   │   └── users.cy.js
│   │   ├── fixtures/
│   │   │   ├── subs-domain-blocklist.csv
│   │   │   └── subs.csv
│   │   ├── plugins/
│   │   │   └── index.js
│   │   └── support/
│   │       ├── commands.js
│   │       ├── e2e.js
│   │       └── reset.sh
│   ├── cypress.config.js
│   ├── email-builder/
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── App/
│   │   │   │   ├── InspectorDrawer/
│   │   │   │   │   ├── ConfigurationPanel/
│   │   │   │   │   │   ├── index.tsx
│   │   │   │   │   │   └── input-panels/
│   │   │   │   │   │       ├── AvatarSidebarPanel.tsx
│   │   │   │   │   │       ├── ButtonSidebarPanel.tsx
│   │   │   │   │   │       ├── ColumnsContainerSidebarPanel.tsx
│   │   │   │   │   │       ├── ContainerSidebarPanel.tsx
│   │   │   │   │   │       ├── DividerSidebarPanel.tsx
│   │   │   │   │   │       ├── EmailLayoutSidebarPanel.tsx
│   │   │   │   │   │       ├── HeadingSidebarPanel.tsx
│   │   │   │   │   │       ├── HtmlSidebarPanel.tsx
│   │   │   │   │   │       ├── ImageSidebarPanel.tsx
│   │   │   │   │   │       ├── SpacerSidebarPanel.tsx
│   │   │   │   │   │       ├── TextSidebarPanel.tsx
│   │   │   │   │   │       └── helpers/
│   │   │   │   │   │           ├── BaseSidebarPanel.tsx
│   │   │   │   │   │           ├── inputs/
│   │   │   │   │   │           │   ├── BooleanInput.tsx
│   │   │   │   │   │           │   ├── ColorInput/
│   │   │   │   │   │           │   │   ├── BaseColorInput.tsx
│   │   │   │   │   │           │   │   ├── Picker.tsx
│   │   │   │   │   │           │   │   ├── Swatch.tsx
│   │   │   │   │   │           │   │   └── index.tsx
│   │   │   │   │   │           │   ├── ColumnWidthsInput.tsx
│   │   │   │   │   │           │   ├── FontFamily.tsx
│   │   │   │   │   │           │   ├── FontSizeInput.tsx
│   │   │   │   │   │           │   ├── FontWeightInput.tsx
│   │   │   │   │   │           │   ├── PaddingInput.tsx
│   │   │   │   │   │           │   ├── RadioGroupInput.tsx
│   │   │   │   │   │           │   ├── SliderInput.tsx
│   │   │   │   │   │           │   ├── TextAlignInput.tsx
│   │   │   │   │   │           │   ├── TextDimensionInput.tsx
│   │   │   │   │   │           │   ├── TextInput.tsx
│   │   │   │   │   │           │   └── raw/
│   │   │   │   │   │           │       └── RawSliderInput.tsx
│   │   │   │   │   │           └── style-inputs/
│   │   │   │   │   │               ├── MultiStylePropertyPanel.tsx
│   │   │   │   │   │               └── SingleStylePropertyPanel.tsx
│   │   │   │   │   ├── StylesPanel.tsx
│   │   │   │   │   ├── ToggleInspectorPanelButton.tsx
│   │   │   │   │   └── index.tsx
│   │   │   │   ├── TemplatePanel/
│   │   │   │   │   ├── DownloadJson/
│   │   │   │   │   │   └── index.tsx
│   │   │   │   │   ├── HtmlPanel.tsx
│   │   │   │   │   ├── ImportJson/
│   │   │   │   │   │   ├── ImportJsonDialog.tsx
│   │   │   │   │   │   ├── index.tsx
│   │   │   │   │   │   └── validateJsonStringValue.ts
│   │   │   │   │   ├── JsonPanel.tsx
│   │   │   │   │   ├── MainTabsGroup.tsx
│   │   │   │   │   ├── ShareButton.tsx
│   │   │   │   │   ├── helper/
│   │   │   │   │   │   ├── HighlightedCodePanel.tsx
│   │   │   │   │   │   └── highlighters.tsx
│   │   │   │   │   └── index.tsx
│   │   │   │   └── index.tsx
│   │   │   ├── documents/
│   │   │   │   ├── blocks/
│   │   │   │   │   ├── ColumnsContainer/
│   │   │   │   │   │   ├── ColumnsContainerEditor.tsx
│   │   │   │   │   │   └── ColumnsContainerPropsSchema.ts
│   │   │   │   │   ├── Container/
│   │   │   │   │   │   ├── ContainerEditor.tsx
│   │   │   │   │   │   └── ContainerPropsSchema.tsx
│   │   │   │   │   ├── EmailLayout/
│   │   │   │   │   │   ├── EmailLayoutEditor.tsx
│   │   │   │   │   │   └── EmailLayoutPropsSchema.tsx
│   │   │   │   │   └── helpers/
│   │   │   │   │       ├── EditorChildrenIds/
│   │   │   │   │       │   ├── AddBlockMenu/
│   │   │   │   │       │   │   ├── BlockButton.tsx
│   │   │   │   │       │   │   ├── BlocksMenu.tsx
│   │   │   │   │       │   │   ├── DividerButton.tsx
│   │   │   │   │       │   │   ├── PlaceholderButton.tsx
│   │   │   │   │       │   │   ├── buttons.tsx
│   │   │   │   │       │   │   └── index.tsx
│   │   │   │   │       │   └── index.tsx
│   │   │   │   │       ├── TStyle.ts
│   │   │   │   │       ├── block-wrappers/
│   │   │   │   │       │   ├── EditorBlockWrapper.tsx
│   │   │   │   │       │   ├── ReaderBlockWrapper.tsx
│   │   │   │   │       │   └── TuneMenu.tsx
│   │   │   │   │       ├── fontFamily.ts
│   │   │   │   │       └── zod.ts
│   │   │   │   └── editor/
│   │   │   │       ├── EditorBlock.tsx
│   │   │   │       ├── EditorContext.tsx
│   │   │   │       └── core.tsx
│   │   │   ├── getConfiguration/
│   │   │   │   ├── index.tsx
│   │   │   │   └── sample/
│   │   │   │       └── empty-email-message.ts
│   │   │   ├── main.tsx
│   │   │   ├── theme.ts
│   │   │   ├── utils.tsx
│   │   │   └── vite-env.d.ts
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   ├── fontello/
│   │   └── config.json
│   ├── index.html
│   ├── jsconfig.json
│   ├── package.json
│   ├── public/
│   │   └── static/
│   │       └── tinymce/
│   │           └── lang/
│   │               ├── cs.js
│   │               ├── de.js
│   │               ├── es_419.js
│   │               ├── fr_FR.js
│   │               ├── it_IT.js
│   │               ├── pl.js
│   │               ├── pt_BR.js
│   │               ├── pt_PT.js
│   │               ├── ro.js
│   │               └── tr.js
│   ├── src/
│   │   ├── App.vue
│   │   ├── api/
│   │   │   └── index.js
│   │   ├── assets/
│   │   │   ├── icons/
│   │   │   │   └── fontello.css
│   │   │   └── style.scss
│   │   ├── components/
│   │   │   ├── BarChart.vue
│   │   │   ├── CampaignPreview.vue
│   │   │   ├── Chart.vue
│   │   │   ├── CodeEditor.vue
│   │   │   ├── CopyText.vue
│   │   │   ├── Editor.vue
│   │   │   ├── EmptyPlaceholder.vue
│   │   │   ├── ListSelector.vue
│   │   │   ├── LogView.vue
│   │   │   ├── Navigation.vue
│   │   │   ├── RichtextEditor.vue
│   │   │   ├── SubscriberActivity.vue
│   │   │   ├── VisualEditor.vue
│   │   │   ├── editor-theme.js
│   │   │   └── editor.js
│   │   ├── constants.js
│   │   ├── main.js
│   │   ├── router/
│   │   │   └── index.js
│   │   ├── store/
│   │   │   └── index.js
│   │   ├── utils.js
│   │   └── views/
│   │       ├── 404.vue
│   │       ├── About.vue
│   │       ├── Bounces.vue
│   │       ├── Campaign.vue
│   │       ├── CampaignAnalytics.vue
│   │       ├── Campaigns.vue
│   │       ├── Dashboard.vue
│   │       ├── Forms.vue
│   │       ├── Import.vue
│   │       ├── ListForm.vue
│   │       ├── Lists.vue
│   │       ├── Logs.vue
│   │       ├── Maintenance.vue
│   │       ├── Media.vue
│   │       ├── RoleForm.vue
│   │       ├── Roles.vue
│   │       ├── Settings.vue
│   │       ├── SubscriberBulkList.vue
│   │       ├── SubscriberForm.vue
│   │       ├── Subscribers.vue
│   │       ├── TemplateForm.vue
│   │       ├── Templates.vue
│   │       ├── UserForm.vue
│   │       ├── UserProfile.vue
│   │       ├── Users.vue
│   │       └── settings/
│   │           ├── appearance.vue
│   │           ├── bounces.vue
│   │           ├── general.vue
│   │           ├── media.vue
│   │           ├── messengers.vue
│   │           ├── performance.vue
│   │           ├── privacy.vue
│   │           ├── security.vue
│   │           └── smtp.vue
│   └── vite.config.js
├── go.mod
├── go.sum
├── i18n/
│   ├── bg.json
│   ├── ca.json
│   ├── cs-cz.json
│   ├── cy.json
│   ├── da.json
│   ├── de.json
│   ├── el.json
│   ├── en.json
│   ├── eo.json
│   ├── es.json
│   ├── fi.json
│   ├── fr-CA.json
│   ├── fr.json
│   ├── he.json
│   ├── hu.json
│   ├── it.json
│   ├── jp.json
│   ├── ko.json
│   ├── ml.json
│   ├── nl.json
│   ├── no.json
│   ├── pl.json
│   ├── pt-BR.json
│   ├── pt.json
│   ├── ro.json
│   ├── ru.json
│   ├── se.json
│   ├── sk.json
│   ├── sl.json
│   ├── tr.json
│   ├── uk.json
│   ├── vi.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── internal/
│   ├── auth/
│   │   ├── auth.go
│   │   └── models.go
│   ├── bounce/
│   │   ├── bounce.go
│   │   ├── mailbox/
│   │   │   ├── opt.go
│   │   │   └── pop.go
│   │   └── webhooks/
│   │       ├── forwardemail.go
│   │       ├── postmark.go
│   │       ├── sendgrid.go
│   │       └── ses.go
│   ├── buflog/
│   │   └── buflog.go
│   ├── captcha/
│   │   └── captcha.go
│   ├── core/
│   │   ├── bounces.go
│   │   ├── campaigns.go
│   │   ├── core.go
│   │   ├── dashboard.go
│   │   ├── lists.go
│   │   ├── media.go
│   │   ├── roles.go
│   │   ├── settings.go
│   │   ├── subscribers.go
│   │   ├── subscriptions.go
│   │   ├── templates.go
│   │   └── users.go
│   ├── events/
│   │   └── events.go
│   ├── i18n/
│   │   └── i18n.go
│   ├── manager/
│   │   ├── manager.go
│   │   ├── message.go
│   │   └── pipe.go
│   ├── media/
│   │   ├── media.go
│   │   └── providers/
│   │       ├── filesystem/
│   │       │   └── filesystem.go
│   │       └── s3/
│   │           └── s3.go
│   ├── messenger/
│   │   ├── email/
│   │   │   └── email.go
│   │   └── postback/
│   │       ├── postback.go
│   │       └── postback_easyjson.go
│   ├── migrations/
│   │   ├── v0.4.0.go
│   │   ├── v0.7.0.go
│   │   ├── v0.8.0.go
│   │   ├── v0.9.0.go
│   │   ├── v1.0.0.go
│   │   ├── v2.0.0.go
│   │   ├── v2.1.0.go
│   │   ├── v2.2.0.go
│   │   ├── v2.3.0.go
│   │   ├── v2.4.0.go
│   │   ├── v2.5.0.go
│   │   ├── v3.0.0.go
│   │   ├── v4.0.0.go
│   │   ├── v4.1.0.go
│   │   ├── v5.0.0.go
│   │   ├── v5.1.0.go
│   │   ├── v6.0.0.go
│   │   └── v6.1.0.go
│   ├── notifs/
│   │   └── notifs.go
│   ├── subimporter/
│   │   └── importer.go
│   ├── tmptokens/
│   │   └── tmptokens.go
│   └── utils/
│       └── utils.go
├── listmonk-simple.service
├── listmonk@.service
├── models/
│   ├── bounces.go
│   ├── campaigns.go
│   ├── common.go
│   ├── lists.go
│   ├── messages.go
│   ├── queries.go
│   ├── settings.go
│   ├── subscribers.go
│   └── templates.go
├── permissions.json
├── project.inlang.json
├── queries/
│   ├── bounces.sql
│   ├── campaigns.sql
│   ├── links.sql
│   ├── lists.sql
│   ├── media.sql
│   ├── misc.sql
│   ├── roles.sql
│   ├── subscribers.sql
│   ├── templates.sql
│   └── users.sql
├── schema.sql
├── scripts/
│   ├── refresh-i18n.sh
│   └── translate-i18n.py
└── static/
    ├── email-templates/
    │   ├── base.html
    │   ├── campaign-status.html
    │   ├── default-archive.tpl
    │   ├── default-visual.json
    │   ├── default-visual.tpl
    │   ├── default.tpl
    │   ├── forgot-password.html
    │   ├── import-status.html
    │   ├── sample-tx.tpl
    │   ├── smtp-test.html
    │   ├── subscriber-data.html
    │   ├── subscriber-optin-campaign.html
    │   └── subscriber-optin.html
    └── public/
        ├── static/
        │   ├── script.js
        │   └── style.css
        └── templates/
            ├── archive.html
            ├── forgot-password.html
            ├── home.html
            ├── index.html
            ├── login-setup.html
            ├── login.html
            ├── message.html
            ├── optin.html
            ├── reset-password.html
            ├── subscription-form.html
            ├── subscription.html
            └── twofa.html

================================================
FILE CONTENTS
================================================

================================================
FILE: .devcontainer/devcontainer.json
================================================
{
  "name": "listmonk",
  "dockerComposeFile": "../dev/docker-compose.yml",
  "service": "backend",
  "workspaceFolder": "/app",
  "forwardPorts": [9000],
  "postStartCommand": "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml"
}


================================================
FILE: .dockerignore
================================================
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

================================================
FILE: .gitattributes
================================================
frontend/* linguist-vendored
VERSION export-subst
* text=auto eol=lf


================================================
FILE: .github/ISSUE_TEMPLATE/confirmed-bug.md
================================================
---
name: Confirmed bug
about: Report an issue that you have definititely confirmed to be a bug
title: ''
labels: bug
assignees: ''

---

**Version:**
 - listmonk: [eg: v1.0.0]
 - OS: [e.g. Fedora]

**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.

**Screenshots:**
If applicable, add screenshots to help explain your problem.


================================================
FILE: .github/ISSUE_TEMPLATE/feature-or-change-request.md
================================================
---
name: Feature or change request
about: Suggest new features or changes to existing features
title: ''
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.


================================================
FILE: .github/ISSUE_TEMPLATE/general-question.md
================================================
---
name: General question
about: You have a question about something or want to start a general discussion
title: ''
labels: 'question'
assignees: ''

---

Note: Please refrain from posting questions about Docker and docker-compose related matters. Please search and refer to the numerous closed issues on these topics. Docker related questions are outside of the purview of this forum and will be closed. Thank you for your understanding.


================================================
FILE: .github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md
================================================
---
name: Possible bug. Needs investigation.
about: Report an issue that could be a bug but is not confirmed yet and needs investigation.
title: ''
labels: ''
assignees: ''

---

**Version:**
 - listmonk: [eg: v1.0.0]
 - OS: [e.g. Fedora]

**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.

**Screenshots:**
If applicable, add screenshots to help explain your problem.


================================================
FILE: .github/workflows/build-sanity.yml
================================================
name: Build Sanity Check

on:
    pull_request:
      types:
        - opened
  
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: "1.26.1"

      - name: Prepare Dependencies and Build
        run: make dist


================================================
FILE: .github/workflows/github-pages.yml
================================================
name: publish-github-pages

on:
  push:
    branches:
      - master
    paths:
      - 'docs/**'
  workflow_dispatch:

permissions:
  contents: write

jobs:
  deploy:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true  # Fetch Hugo themes
          fetch-depth: 0    # Fetch all history for .GitInfo and .Lastmod

      - uses: actions/setup-python@v2
        with:
          python-version: 3.x
      - run: pip install mkdocs-material

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: '0.68.3'

      # Build the main site to the docs/publish directory. This will be the root (/) in gh-pages.
      # The -d (output) path is relative to the -s (source) path
      - name: Build main site
        run: hugo -s docs/site -d ../publish --gc --minify

      # Build the mkdocs documentation in the docs/publish/docs dir. This will be at (/docs)
      # The -d (output) path is relative to the -f (source) path
      - name: Build docs site
        run: mkdocs build -f docs/docs/mkdocs.yml -d ../publish/docs

      # Copy the static i18n app to the publish directory. This will be at (/i18n)
      - name: Copy i18n site
        run: cp -R docs/i18n docs/publish

      - name: Generate Swagger UI
        uses: Legion2/swagger-ui-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          spec-file: ./docs/swagger/collections.yaml
          output: ./docs/publish/docs/swagger

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_branch: gh-pages
          publish_dir: ./docs/publish
          cname: listmonk.app
          user_name: 'github-actions[bot]'
          user_email: 'github-actions[bot]@users.noreply.github.com'


================================================
FILE: .github/workflows/hodor-review.yml
================================================
name: Hodor AI Code Review

on:
  pull_request_target:
    types: [labeled, synchronize]
    paths-ignore:
      - 'i18n/**'
      - '*.md'
      - 'LICENSE'
      - '.gitignore'

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    if: >-
      (github.event.action == 'labeled' && github.event.label.name == 'hodor-review') ||
      (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'hodor-review'))
    runs-on: ubuntu-latest
    steps:
      - name: Run Hodor review
        run: |
          docker run --rm \
            -e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \
            -e LLM_API_KEY=${{ secrets.LLM_API_KEY }} \
            ghcr.io/mr-karan/hodor:0.3.4 \
            "https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}" \
            --model "${{ vars.HODOR_MODEL || 'gpt-5.2' }}" \
            --post


================================================
FILE: .github/workflows/issues.yml
================================================
name: "close-stale-issues-and-prs"
on:
  schedule:
    - cron: "30 1 * * *"
  workflow_dispatch:

jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v9
        with:
          days-before-stale: 150
          stale-issue-label: "stale"
          stale-pr-label: "stale"
          debug-only: false
          exempt-all-assignees: true
          operations-per-run: 1000
          stale-issue-message: "This issue has been marked 'stale' after 5 months of inactivity. If there is no further activity, it will be closed in 7 days."
          stale-pr-message: "This PR has been marked 'stale' after 5 months of inactivity. If there is no further activity, it will be closed in 7 days."


================================================
FILE: .github/workflows/nightly.yml
================================================
name: nightly
on:
  schedule:
    - cron: "0 2 * * *"
  workflow_dispatch:

permissions:
  contents: write
  packages: write

jobs:
  check:
    runs-on: ubuntu-latest
    outputs:
      skip: ${{ steps.check_changes.outputs.skip }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Check for changes since last nightly release
        id: check_changes
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          LAST_NIGHTLY_SHA=$(gh release view nightly --json targetCommitish -q '.targetCommitish' 2>/dev/null || echo "")
          CURRENT_SHA=$(git rev-parse HEAD)
          echo "Last nightly SHA: $LAST_NIGHTLY_SHA"
          echo "Current SHA: $CURRENT_SHA"
          if [ -n "$LAST_NIGHTLY_SHA" ] && [ "$LAST_NIGHTLY_SHA" = "$CURRENT_SHA" ]; then
            echo "No changes since last nightly build, skipping ..."
            echo "skip=true" >> $GITHUB_OUTPUT
          else
            echo "Changes detected, proceeding with build ..."
            echo "skip=false" >> $GITHUB_OUTPUT
          fi

  nightly:
    needs: check
    if: needs.check.outputs.skip != 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: "1.26.1"

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Delete existing nightly release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: gh release delete nightly --yes --cleanup-tag 2>/dev/null || true

      - name: Set nightly date
        id: tag
        run: |
          NIGHTLY_DATE=$(date -u +%Y-%m-%d)
          echo "date=$NIGHTLY_DATE" >> $GITHUB_OUTPUT

      - name: Prepare dependencies
        run: make dist
        env:
          LISTMONK_VERSION: nightly-${{ steps.tag.outputs.date }}

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          version: latest
          args: release --snapshot --parallelism 1 --clean --config .goreleaser-nightly.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          LISTMONK_VERSION: nightly-${{ steps.tag.outputs.date }}

      - name: Push Docker images
        run: |
          # Push all architecture-specific images
          docker push listmonk/listmonk:nightly-amd64
          docker push listmonk/listmonk:nightly-arm64v8
          docker push listmonk/listmonk:nightly-armv6
          docker push listmonk/listmonk:nightly-armv7
          docker push ghcr.io/knadh/listmonk:nightly-amd64
          docker push ghcr.io/knadh/listmonk:nightly-arm64v8
          docker push ghcr.io/knadh/listmonk:nightly-armv6
          docker push ghcr.io/knadh/listmonk:nightly-armv7

      - name: Create and push Docker manifests
        run: |
          # Docker Hub manifest
          docker buildx imagetools create -t listmonk/listmonk:nightly \
            listmonk/listmonk:nightly-amd64 \
            listmonk/listmonk:nightly-arm64v8 \
            listmonk/listmonk:nightly-armv6 \
            listmonk/listmonk:nightly-armv7

          # GHCR manifest
          docker buildx imagetools create -t ghcr.io/knadh/listmonk:nightly \
            ghcr.io/knadh/listmonk:nightly-amd64 \
            ghcr.io/knadh/listmonk:nightly-arm64v8 \
            ghcr.io/knadh/listmonk:nightly-armv6 \
            ghcr.io/knadh/listmonk:nightly-armv7

      - name: Verify Docker manifests
        run: |
          docker buildx imagetools inspect listmonk/listmonk:nightly
          docker buildx imagetools inspect ghcr.io/knadh/listmonk:nightly

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release create nightly \
            --title "Nightly release" \
            --notes "
          > **Warning**: This is an automated nightly build from the master branch.
          > It may contain bugs and breaking changes. Use at your own risk.
          > Available on Docker Hub and GitHub Container Registry as `listmonk/listmonk:nightly`.
          > For stable releases, please use a versioned release. [Learn more](https://listmonk.app/docs/installation/#nightly)

          Built from commit: $(git rev-parse --short HEAD)" \
            --prerelease \
            --target $(git rev-parse HEAD) \
            dist/*.tar.gz


================================================
FILE: .github/workflows/release.yml
================================================
name: goreleaser

on:
  push:
    tags:
      - "v*" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0.1.0`)

permissions: write-all

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: "1.26.1"

      - name: Login to Docker Registry
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Login to GitHub Docker Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Prepare Dependencies
        run: |
          make dist

      - name: Check Docker Version
        run: |
          docker version

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          version: latest
          args: release --parallelism 1 --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


================================================
FILE: .gitignore
================================================
frontend/node_modules/
frontend/.cache/
frontend/yarn.lock
frontend/build/
frontend/public/static/email-builder/
frontend/dist/
frontend/email-builder/dist/
email-builder/node_modules/
email-builder/.cache/
email-builder/yarn.lock
email-builder/dist/
static/public/static/altcha.umd.js
.vscode/

config.toml
docker-compose.override.yml
node_modules
listmonk
dist/*
uploads/


================================================
FILE: .go-version
================================================
1.26.1


================================================
FILE: .goreleaser-nightly.yml
================================================
version: 2

snapshot:
  version_template: "{{ .Env.LISTMONK_VERSION }}"

# GoReleaser config for nightly builds

env:
  - GO111MODULE=on
  - CGO_ENABLED=0
  - GITHUB_ORG=knadh
  - DOCKER_ORG=listmonk

before:
  hooks:
    - make build-frontend

builds:
  - binary: listmonk
    main: ./cmd
    goos:
      - linux
      - windows
      - darwin
      - freebsd
      - openbsd
      - netbsd
    goarch:
      - amd64
      - arm64
      - arm
    goarm:
      - 6
      - 7
    ignore:
      - goos: windows
        goarch: arm
    ldflags:
      - -s -w -X "main.buildString=nightly ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Env.LISTMONK_VERSION }}"

    hooks:
      # stuff executables with static assets.
      post: make pack-bin BIN={{ .Path }}

archives:
  - format: tar.gz
    name_template: "listmonk_nightly_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
    files:
      - README.md
      - LICENSE

dockers:
  - use: buildx
    goos: linux
    goarch: amd64
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-amd64"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-amd64"
    build_flag_templates:
      - --platform=linux/amd64
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version=nightly
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh
  - use: buildx
    goos: linux
    goarch: arm64
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-arm64v8"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-arm64v8"
    build_flag_templates:
      - --platform=linux/arm64/v8
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version=nightly
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh
  - use: buildx
    goos: linux
    goarch: arm
    goarm: 6
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-armv6"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-armv6"
    build_flag_templates:
      - --platform=linux/arm/v6
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version=nightly
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh
  - use: buildx
    goos: linux
    goarch: arm
    goarm: 7
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-armv7"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-armv7"
    build_flag_templates:
      - --platform=linux/arm/v7
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version=nightly
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh

docker_manifests:
  - name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly"
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-amd64"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-arm64v8"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-armv6"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:nightly-armv7"
  - name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly
    image_templates:
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-amd64
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-arm64v8
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-armv6
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:nightly-armv7

changelog:
  disable: true

release:
  prerelease: true
  name_template: "Nightly Build"
  header: |
    ## Nightly Build

    > **Warning**: This is an automated nightly build from the master branch.
    > It may contain bugs and breaking changes. Use at your own risk.
    > For stable releases, please use a versioned release.

    Built from commit: {{ .ShortCommit }}


================================================
FILE: .goreleaser.yml
================================================
env:
  - GO111MODULE=on
  - CGO_ENABLED=0
  - GITHUB_ORG=knadh
  - DOCKER_ORG=listmonk

before:
  hooks:
    - make build-frontend

builds:
  - binary: listmonk
    main: ./cmd
    goos:
      - linux
      - windows
      - darwin
      - freebsd
      - openbsd
      - netbsd
    goarch:
      - amd64
      - arm64
      - arm
    goarm:
      - 6
      - 7
    ignore:
      - goos: windows
        goarch: arm
    ldflags:
      - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"

    hooks:
      # stuff executables with static assets.
      post: make pack-bin BIN={{ .Path }}

archives:
  - format: tar.gz
    files:
      - README.md
      - LICENSE

dockers:
  - use: buildx
    goos: linux
    goarch: amd64
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
    build_flag_templates:
      - --platform=linux/amd64
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version={{ .Version }}
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh
  - use: buildx
    goos: linux
    goarch: arm64
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64v8"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64v8"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8"
    build_flag_templates:
      - --platform=linux/arm64/v8
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version={{ .Version }}
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh
  - use: buildx
    goos: linux
    goarch: arm
    goarm: 6
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
    build_flag_templates:
      - --platform=linux/arm/v6
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version={{ .Version }}
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh
  - use: buildx
    goos: linux
    goarch: arm
    goarm: 7
    ids:
      - listmonk
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7"
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
    build_flag_templates:
      - --platform=linux/arm/v7
      - --label=org.opencontainers.image.title={{ .ProjectName }}
      - --label=org.opencontainers.image.description={{ .ProjectName }}
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
      - --label=org.opencontainers.image.version={{ .Version }}
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
      - --label=org.opencontainers.image.licenses=AGPL-3.0
    dockerfile: Dockerfile
    extra_files:
      - config.toml.sample
      - docker-entrypoint.sh

docker_manifests:
  - name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest"
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64v8"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
  - name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}"
    image_templates:
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
  - name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest
    image_templates:
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64v8
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7
  - name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}
    image_templates:
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7


================================================
FILE: CONTRIBUTING.md
================================================
# 1. Contributing

Welcome to listmonk! You can contribute to the project in the following ways:

1. **Bug reports:** One liner reports are difficult to understand and review.
	1. Follow the bug reporting issue template and provide clear, concise descriptions and steps to reproduce the bug.
	2. Ensure that you have searched the existing issues to avoid duplicates.
	3. Maintainers may close unclear issues that lack enough information to reproduce a bug. [Report a bug here](https://github.com/knadh/listmonk/issues/new?assignees=&labels=bug&template=bug_report.md).

2. **Feature suggestions:** If you feel there is a nice enhancement or feature that can benefit many users, please open a feature request issue.
	1. Ensure that you have searched the existing issues to avoid duplicates.
	2. What makes sense for the project, what suits its scope and goals, and its future direction are at the discretion of the maintainers who put in the time, effort, and energy in building and maintaining the project for free. Please be respectful of this and keep discussions friendly and fruitful.
	3. It is the responsibility of the requester to clearly explain and justify why a change is warranted. It is not the responsibility of the maintainers to coax this information out of a requester. So, please post well researched, well thought out, and detailed feature requests saving everyone time.
	4. Maintainers may close unclear feature requests that lack enough information. [Suggest a feature here](https://github.com/knadh/listmonk/issues/new?assignees=&labels=enhancement&template=feature-or-change-request.md&title=).

3. **Improving docs:** You can submit corrections and improvements to the [documentation](https://listmonk.app/docs) website on the [docs repo](https://github.com/knadh/listmonk/tree/master/docs).

4. **i18n translations:** The project is available in many languages thanks to user contributions. You can create a new language pack or submit corrections to existing ones. There is a UI available for making translations easy. [More info here](https://listmonk.app/docs/i18n/).


# 2. Pull requests

This is a tricky one for many reasons. A PR, be it a new feature or a small enhancement, has to make sense to the project's overall scope, goals, and technical aspects. The quality, style, and conventions of the code have to conform to that of the project's. Performance, usability, stability and other kinds of impacts of a PR should be well understood.

This makes reviewing PRs a difficult and time consuming task. The bigger a PR, the more difficult it is to understand. Reviewing a PR in detail, engaging in back and forth discussions to improve it, and deciding that it is meaningful and safe to merge can often require more time and effort than what has gone into creating a PR. Thus, ultimately, whether a PR gets accepted or not, for whatever reason, is at the discretion of the maintainers. Please be respectful of the fact that maintainers have a much deeper understanding of the overall project. So, nitpicking on micro aspects may not be meaningful.

To keep the process smooth:

1. **Send a proposal first:** Open an issue describing what you aim to accomplish, how it makes sense to the project, and how you plan on implementing it (with useful technical details), before committing time and effort to writing code. This saves everyone time.

2. **Send small PRs:** Whenever possible, send small PRs with well defined scopes. The smaller the PR, the easier it is to review and test. Bundling multiple features into a single PR is highly discouraged. 

3. **PRs will be squashed in the end:** A PR may change considerably with multiple commits before it is approved. Once a PR is approved, if there are multiple commits, they will be squashed into a single commit during merging.


# 3. Be respectful

Remember, most FOSS projects are fruits of love and labour of maintainers who share them with the world for free with no expectations of any returns. Free as in freedom, and free as in beer too. Really, *some people just want to watch the world turn*.

So:

1. Please be respectful and refrain from using aggressive or snarky language. It wastes time, cognitive bandwidth, and goodwill.
2. Please refrain from demanding. How badly you want a feature has no bearing on whether it warrants a maintainer's time or attention. It is entirely up to the maintainers, if, how, and when they want to implement something.
3. Please do not nitpick and generate unnecessary discussions that waste time.
4. Please make sure you have searched the docs and issues before asking support questions.
5. **Please remember, FOSS project maintainers owe you nothing** (unless you have an explicit agreement with them, of course) including their time in responding to your messages or providing free customer support. If you want to be heard, please be respectful and establish goodwill.
6. If these are unacceptable to you a) you don't have to use the project b) you can always fork the project and change it to your liking while adhering to the terms of the license. That is the beauty of FOSS, afterall.

Thank you!


================================================
FILE: Dockerfile
================================================
FROM alpine:latest

# Install dependencies
RUN apk --no-cache add ca-certificates tzdata shadow su-exec

# Set the working directory
WORKDIR /listmonk

# Copy only the necessary files
COPY listmonk .
COPY config.toml.sample config.toml

# Copy the entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/

# Make the entrypoint script executable
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

# Expose the application port
EXPOSE 9000

# Set the entrypoint
ENTRYPOINT ["docker-entrypoint.sh"]

# Define the command to run the application
CMD ["./listmonk"]


================================================
FILE: LICENSE
================================================
                    GNU AFFERO GENERAL PUBLIC LICENSE
                       Version 3, 19 November 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

                            Preamble

  The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.

  The licenses for most software and other practical works are designed
to take away your freedom to share and change the works.  By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.

  Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.

  A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate.  Many developers of free software are heartened and
encouraged by the resulting cooperation.  However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.

  The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community.  It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server.  Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.

  An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals.  This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.

  The precise terms and conditions for copying, distribution and
modification follow.

                       TERMS AND CONDITIONS

  0. Definitions.

  "This License" refers to version 3 of the GNU Affero General Public License.

  "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.

  "The Program" refers to any copyrightable work licensed under this
License.  Each licensee is addressed as "you".  "Licensees" and
"recipients" may be individuals or organizations.

  To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy.  The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.

  A "covered work" means either the unmodified Program or a work based
on the Program.

  To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy.  Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.

  To "convey" a work means any kind of propagation that enables other
parties to make or receive copies.  Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.

  An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License.  If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.

  1. Source Code.

  The "source code" for a work means the preferred form of the work
for making modifications to it.  "Object code" means any non-source
form of a work.

  A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.

  The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form.  A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.

  The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities.  However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work.  For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.

  The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.

  The Corresponding Source for a work in source code form is that
same work.

  2. Basic Permissions.

  All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met.  This License explicitly affirms your unlimited
permission to run the unmodified Program.  The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work.  This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.

  You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force.  You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright.  Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.

  Conveying under any other circumstances is permitted solely under
the conditions stated below.  Sublicensing is not allowed; section 10
makes it unnecessary.

  3. Protecting Users' Legal Rights From Anti-Circumvention Law.

  No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.

  When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.

  4. Conveying Verbatim Copies.

  You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.

  You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.

  5. Conveying Modified Source Versions.

  You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:

    a) The work must carry prominent notices stating that you modified
    it, and giving a relevant date.

    b) The work must carry prominent notices stating that it is
    released under this License and any conditions added under section
    7.  This requirement modifies the requirement in section 4 to
    "keep intact all notices".

    c) You must license the entire work, as a whole, under this
    License to anyone who comes into possession of a copy.  This
    License will therefore apply, along with any applicable section 7
    additional terms, to the whole of the work, and all its parts,
    regardless of how they are packaged.  This License gives no
    permission to license the work in any other way, but it does not
    invalidate such permission if you have separately received it.

    d) If the work has interactive user interfaces, each must display
    Appropriate Legal Notices; however, if the Program has interactive
    interfaces that do not display Appropriate Legal Notices, your
    work need not make them do so.

  A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit.  Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.

  6. Conveying Non-Source Forms.

  You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:

    a) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by the
    Corresponding Source fixed on a durable physical medium
    customarily used for software interchange.

    b) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by a
    written offer, valid for at least three years and valid for as
    long as you offer spare parts or customer support for that product
    model, to give anyone who possesses the object code either (1) a
    copy of the Corresponding Source for all the software in the
    product that is covered by this License, on a durable physical
    medium customarily used for software interchange, for a price no
    more than your reasonable cost of physically performing this
    conveying of source, or (2) access to copy the
    Corresponding Source from a network server at no charge.

    c) Convey individual copies of the object code with a copy of the
    written offer to provide the Corresponding Source.  This
    alternative is allowed only occasionally and noncommercially, and
    only if you received the object code with such an offer, in accord
    with subsection 6b.

    d) Convey the object code by offering access from a designated
    place (gratis or for a charge), and offer equivalent access to the
    Corresponding Source in the same way through the same place at no
    further charge.  You need not require recipients to copy the
    Corresponding Source along with the object code.  If the place to
    copy the object code is a network server, the Corresponding Source
    may be on a different server (operated by you or a third party)
    that supports equivalent copying facilities, provided you maintain
    clear directions next to the object code saying where to find the
    Corresponding Source.  Regardless of what server hosts the
    Corresponding Source, you remain obligated to ensure that it is
    available for as long as needed to satisfy these requirements.

    e) Convey the object code using peer-to-peer transmission, provided
    you inform other peers where the object code and Corresponding
    Source of the work are being offered to the general public at no
    charge under subsection 6d.

  A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.

  A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling.  In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage.  For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product.  A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.

  "Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source.  The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.

  If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information.  But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).

  The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed.  Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.

  Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.

  7. Additional Terms.

  "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law.  If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.

  When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it.  (Additional permissions may be written to require their own
removal in certain cases when you modify the work.)  You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.

  Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:

    a) Disclaiming warranty or limiting liability differently from the
    terms of sections 15 and 16 of this License; or

    b) Requiring preservation of specified reasonable legal notices or
    author attributions in that material or in the Appropriate Legal
    Notices displayed by works containing it; or

    c) Prohibiting misrepresentation of the origin of that material, or
    requiring that modified versions of such material be marked in
    reasonable ways as different from the original version; or

    d) Limiting the use for publicity purposes of names of licensors or
    authors of the material; or

    e) Declining to grant rights under trademark law for use of some
    trade names, trademarks, or service marks; or

    f) Requiring indemnification of licensors and authors of that
    material by anyone who conveys the material (or modified versions of
    it) with contractual assumptions of liability to the recipient, for
    any liability that these contractual assumptions directly impose on
    those licensors and authors.

  All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10.  If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term.  If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.

  If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.

  Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.

  8. Termination.

  You may not propagate or modify a covered work except as expressly
provided under this License.  Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).

  However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.

  Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.

  Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License.  If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.

  9. Acceptance Not Required for Having Copies.

  You are not required to accept this License in order to receive or
run a copy of the Program.  Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance.  However,
nothing other than this License grants you permission to propagate or
modify any covered work.  These actions infringe copyright if you do
not accept this License.  Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.

  10. Automatic Licensing of Downstream Recipients.

  Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License.  You are not responsible
for enforcing compliance by third parties with this License.

  An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations.  If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.

  You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License.  For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.

  11. Patents.

  A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based.  The
work thus licensed is called the contributor's "contributor version".

  A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version.  For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.

  Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.

  In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement).  To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.

  If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients.  "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.

  If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.

  A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License.  You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.

  Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.

  12. No Surrender of Others' Freedom.

  If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all.  For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.

  13. Remote Network Interaction; Use with the GNU General Public License.

  Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software.  This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.

  Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work.  The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.

  14. Revised Versions of this License.

  The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time.  Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

  Each version is given a distinguishing version number.  If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation.  If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.

  If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.

  Later license versions may give you additional or different
permissions.  However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.

  15. Disclaimer of Warranty.

  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

  16. Limitation of Liability.

  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.

  17. Interpretation of Sections 15 and 16.

  If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.

                     END OF TERMS AND CONDITIONS

            How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published
    by the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.

Also add information on how to contact you by electronic and paper mail.

  If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source.  For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code.  There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.

  You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.


================================================
FILE: Makefile
================================================
# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"),"")

# Try to get the semver from 1) git 2) the VERSION file 3) fallback.
VERSION := $(or $(LISTMONK_VERSION),$(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0")

BUILDDATE := $(if $(SOURCE_DATE_EPOCH),$(shell date -u -d @$(SOURCE_DATE_EPOCH) +"%Y-%m-%dT%H:%M:%S%z"),$(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(BUILDDATE))

YARN ?= yarn
GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
FRONTEND_YARN_MODULES = frontend/node_modules
FRONTEND_DIST = frontend/dist
FRONTEND_EMAIL_BUILDER_DIST_FINAL = frontend/public/static/email-builder
FRONTEND_DEPS = \
	$(FRONTEND_YARN_MODULES) \
	$(FRONTEND_EMAIL_BUILDER_DIST_FINAL) \
	frontend/index.html \
	frontend/package.json \
	frontend/vite.config.js \
	frontend/.eslintrc.js \
	$(shell find frontend/fontello frontend/public frontend/src -type f)

FRONTEND_EMAIL_BUILDER = frontend/email-builder
FRONTEND_EMAIL_BUILDER_YARN_MODULES = $(FRONTEND_EMAIL_BUILDER)/node_modules
FRONTEND_EMAIL_BUILDER_DIST = $(FRONTEND_EMAIL_BUILDER)/dist
FRONTEND_EMAIL_BUILDER_DEPS = \
	$(FRONTEND_EMAIL_BUILDER_YARN_MODULES) \
	$(FRONTEND_EMAIL_BUILDER)/package.json \
	$(FRONTEND_EMAIL_BUILDER)/tsconfig.json \
	$(FRONTEND_EMAIL_BUILDER)/vite.config.ts \
	$(shell find $(FRONTEND_EMAIL_BUILDER)/src -type f)

BIN := listmonk
STATIC := config.toml.sample \
	schema.sql queries:/queries permissions.json \
	static/public:/public \
	static/email-templates \
	frontend/dist:/admin \
	i18n:/i18n

SQL := $(shell find . -type f -name "*.sql") $(shell find queries -type f -name "*.sql")
SRC := $(shell find . -type f -name "*.go")

.PHONY: build
build: $(BIN)

$(STUFFBIN):
	go install github.com/knadh/stuffbin/...

$(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
	cd frontend && $(YARN) install
	touch -c $(FRONTEND_YARN_MODULES)

$(FRONTEND_EMAIL_BUILDER_YARN_MODULES): frontend/package.json frontend/yarn.lock
	cd $(FRONTEND_EMAIL_BUILDER) && $(YARN) install
	touch -c $(FRONTEND_EMAIL_BUILDER_YARN_MODULES)

# Build the backend to ./listmonk.
$(BIN): $(SRC) go.mod go.sum schema.sql $(SQL) permissions.json
	CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go

# Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
.PHONY: run
run:
	CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go

# Build the JS frontend into frontend/dist.
$(FRONTEND_DIST): $(FRONTEND_DEPS)
	export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) build
	touch -c $(FRONTEND_DIST)

# Build the JS email-builder dist.
$(FRONTEND_EMAIL_BUILDER_DIST): $(FRONTEND_EMAIL_BUILDER_DEPS)
	export VUE_APP_VERSION="${VERSION}" && cd $(FRONTEND_EMAIL_BUILDER) && $(YARN) build
	touch -c $(FRONTEND_EMAIL_BUILDER_DIST)

# Copy the build assets to frontend.
$(FRONTEND_EMAIL_BUILDER_DIST_FINAL): $(FRONTEND_EMAIL_BUILDER_DIST)
	mkdir -p $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
	cp -r $(FRONTEND_EMAIL_BUILDER_DIST)/* $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
	touch -c $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)

.PHONY: build-frontend
build-frontend: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL) $(FRONTEND_DIST)

.PHONY: build-email-builder
build-email-builder: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)

# Run the JS frontend server in dev mode.
.PHONY: run-frontend
run-frontend: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
	export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) dev

# Run Go tests.
.PHONY: test
test:
	go test ./...

# Bundle all static assets including the JS frontend into the ./listmonk binary
# using stuffbin (installed with make deps).
.PHONY: dist
dist: $(STUFFBIN) build build-frontend pack-bin

# pack-releases runns stuffbin packing on the given binary. This is used
# in the .goreleaser post-build hook.
.PHONY: pack-bin
pack-bin: build-frontend $(BIN) $(STUFFBIN)
	$(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC}

# Use goreleaser to do a dry run producing local builds.
.PHONY: release-dry
release-dry:
	goreleaser release --parallelism 1 --clean --snapshot --skip=publish

# Use goreleaser to build production releases and publish them.
.PHONY: release
release:
	goreleaser release --parallelism 1 --clean

# Build local docker images for development.
.PHONY: build-dev-docker
build-dev-docker: build ## Build docker containers for the entire suite (Front/Core/PG).
	cd dev; \
	docker compose build ; \

# Spin a local docker suite for local development.
.PHONY: dev-docker
dev-docker: build-dev-docker ## Build and spawns docker containers for the entire suite (Front/Core/PG).
	cd dev; \
	docker compose up

# Run the backend in docker-dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
.PHONY: run-backend-docker
run-backend-docker:
	CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go --config=dev/config.toml

# Tear down the complete local development docker suite.
.PHONY: rm-dev-docker
rm-dev-docker: build ## Delete the docker containers including DB volumes.
	cd dev; \
	docker compose down -v ; \

# Setup the db for local dev docker suite.
.PHONY: init-dev-docker
init-dev-docker: build-dev-docker ## Delete the docker containers including DB volumes.
	cd dev; \
	docker compose run --rm backend sh -c "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml"


================================================
FILE: README.md
================================================
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>

[![listmonk-logo](https://user-images.githubusercontent.com/547147/231084896-835dba66-2dfe-497c-ba0f-787564c0819e.png)](https://listmonk.app)

listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL database as its data store.

[![listmonk-dashboard](https://github.com/user-attachments/assets/689b5fbb-dd25-4956-a36f-e3226a65f9c4)](https://listmonk.app)

Visit [listmonk.app](https://listmonk.app) for more info. Check out the [**live demo**](https://demo.listmonk.app).

## Installation

### Docker

The latest image is available on DockerHub at [`listmonk/listmonk:latest`](https://hub.docker.com/r/listmonk/listmonk/tags?page=1&ordering=last_updated&name=latest).
Download and use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml).


```shell
# Download the compose file to the current directory.
curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml

# Run the services in the background.
docker compose up -d
```
Visit `http://localhost:9000`

See [installation docs](https://listmonk.app/docs/installation)

__________________

### Binary
- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary.
- `./listmonk --new-config` to generate config.toml. Edit it.
- `./listmonk --install` to setup the Postgres DB (or `--upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects).
- Run `./listmonk` and visit `http://localhost:9000`

See [installation docs](https://listmonk.app/docs/installation)
__________________


## Developers
listmonk is free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI. 


## License
listmonk is licensed under the AGPL v3 license.


================================================
FILE: SECURITY.md
================================================
# Reporting security issues

Please refer to https://listmonk.app/docs/security-reports/ first to see the list of non-issues and acceptable-risks before reporting a vulnerability.



================================================
FILE: VERSION
================================================
$Format:%h$
$Format:%D$


================================================
FILE: cmd/admin.go
================================================
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"syscall"
	"time"

	"github.com/knadh/listmonk/internal/captcha"
	"github.com/labstack/echo/v4"
	null "gopkg.in/volatiletech/null.v6"
)

type serverConfig struct {
	RootURL            string `json:"root_url"`
	FromEmail          string `json:"from_email"`
	PublicSubscription struct {
		Enabled          bool        `json:"enabled"`
		CaptchaEnabled   bool        `json:"captcha_enabled"`
		CaptchaProvider  null.String `json:"captcha_provider"`
		CaptchaKey       null.String `json:"captcha_key"`
		AltchaComplexity int         `json:"altcha_complexity"`
	} `json:"public_subscription"`
	Privacy struct {
		DisableTracking    bool `json:"disable_tracking"`
		IndividualTracking bool `json:"individual_tracking"`
	} `json:"privacy"`
	MediaProvider string          `json:"media_provider"`
	Messengers    []string        `json:"messengers"`
	Langs         []i18nLang      `json:"langs"`
	Lang          string          `json:"lang"`
	Permissions   json.RawMessage `json:"permissions"`
	Update        *AppUpdate      `json:"update"`
	NeedsRestart  bool            `json:"needs_restart"`
	HasLegacyUser bool            `json:"has_legacy_user"`
	Version       string          `json:"version"`
}

// GetServerConfig returns general server config.
func (a *App) GetServerConfig(c echo.Context) error {
	out := serverConfig{
		RootURL:       a.urlCfg.RootURL,
		FromEmail:     a.cfg.FromEmail,
		Lang:          a.cfg.Lang,
		Permissions:   a.cfg.PermissionsRaw,
		HasLegacyUser: a.cfg.HasLegacyUser,
		Privacy: struct {
			DisableTracking    bool `json:"disable_tracking"`
			IndividualTracking bool `json:"individual_tracking"`
		}{
			DisableTracking:    a.cfg.Privacy.DisableTracking,
			IndividualTracking: a.cfg.Privacy.IndividualTracking,
		},
	}
	out.PublicSubscription.Enabled = a.cfg.EnablePublicSubPage

	// CAPTCHA.
	if a.cfg.Security.Captcha.Altcha.Enabled {
		out.PublicSubscription.CaptchaEnabled = true
		out.PublicSubscription.CaptchaProvider = null.StringFrom(captcha.ProviderAltcha)
		out.PublicSubscription.AltchaComplexity = a.cfg.Security.Captcha.Altcha.Complexity
	} else if a.cfg.Security.Captcha.HCaptcha.Enabled {
		out.PublicSubscription.CaptchaEnabled = true
		out.PublicSubscription.CaptchaProvider = null.StringFrom(captcha.ProviderHCaptcha)
		out.PublicSubscription.CaptchaKey = null.StringFrom(a.cfg.Security.Captcha.HCaptcha.Key)
	}

	out.MediaProvider = a.cfg.MediaUpload.Provider

	// Language list.
	langList, err := getI18nLangList(a.fs)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError,
			fmt.Sprintf("Error loading language list: %v", err))
	}
	out.Langs = langList

	out.Messengers = make([]string, 0, len(a.messengers))
	for _, m := range a.messengers {
		out.Messengers = append(out.Messengers, m.Name())
	}

	a.Lock()
	out.NeedsRestart = a.needsRestart
	out.Update = a.update
	a.Unlock()
	out.Version = versionString

	return c.JSON(http.StatusOK, okResp{out})
}

// GetDashboardCharts returns chart data points to render ont he dashboard.
func (a *App) GetDashboardCharts(c echo.Context) error {
	// Get the chart data from the DB.
	out, err := a.core.GetDashboardCharts()
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// GetDashboardCounts returns stats counts to show on the dashboard.
func (a *App) GetDashboardCounts(c echo.Context) error {
	// Get the chart data from the DB.
	out, err := a.core.GetDashboardCounts()
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// ReloadApp sends a reload signal to the app, causing a full restart.
func (a *App) ReloadApp(c echo.Context) error {
	go func() {
		<-time.After(time.Millisecond * 500)

		// Send the reload signal to trigger the wait loop in main.
		a.chReload <- syscall.SIGHUP
	}()

	return c.JSON(http.StatusOK, okResp{true})
}


================================================
FILE: cmd/archive.go
================================================
package main

import (
	"bytes"
	"encoding/json"
	"html/template"
	"net/http"
	"net/url"

	"github.com/gorilla/feeds"
	"github.com/knadh/listmonk/internal/manager"
	"github.com/knadh/listmonk/models"
	"github.com/labstack/echo/v4"
	null "gopkg.in/volatiletech/null.v6"
)

type campArchive struct {
	UUID      string    `json:"uuid"`
	Subject   string    `json:"subject"`
	Content   string    `json:"content"`
	CreatedAt null.Time `json:"created_at"`
	SendAt    null.Time `json:"send_at"`
	URL       string    `json:"url"`
}

// GetCampaignArchives renders the public campaign archives page.
func (a *App) GetCampaignArchives(c echo.Context) error {
	// Get archives from the DB.
	pg := a.pg.NewFromURL(c.Request().URL.Query())
	camps, total, err := a.getCampaignArchives(pg.Offset, pg.Limit, false)
	if err != nil {
		return err
	}

	if len(camps) == 0 {
		return c.JSON(http.StatusOK, okResp{models.PageResults{
			Results: []campArchive{},
		}})
	}

	// Meta.
	out := models.PageResults{
		Results: camps,
		Total:   total,
		Page:    pg.Page,
		PerPage: pg.PerPage,
	}

	return c.JSON(200, okResp{out})
}

// GetCampaignArchivesFeed renders the public campaign archives RSS feed.
func (a *App) GetCampaignArchivesFeed(c echo.Context) error {
	var (
		pg              = a.pg.NewFromURL(c.Request().URL.Query())
		showFullContent = a.cfg.EnablePublicArchiveRSSContent
	)

	// Get archives from the DB.
	camps, _, err := a.getCampaignArchives(pg.Offset, pg.Limit, showFullContent)
	if err != nil {
		return err
	}

	// Format output for the feed.
	out := make([]*feeds.Item, 0, len(camps))
	for _, c := range camps {
		pubDate := c.CreatedAt.Time

		if c.SendAt.Valid {
			pubDate = c.SendAt.Time
		}

		out = append(out, &feeds.Item{
			Title:   c.Subject,
			Link:    &feeds.Link{Href: c.URL},
			Content: c.Content,
			Created: pubDate,
		})
	}

	// Generate the feed.
	feed := &feeds.Feed{
		Title:       a.cfg.SiteName,
		Link:        &feeds.Link{Href: a.urlCfg.RootURL},
		Description: a.i18n.T("public.archiveTitle"),
		Items:       out,
	}

	if err := feed.WriteRss(c.Response().Writer); err != nil {
		a.log.Printf("error generating archive RSS feed: %v", err)
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("public.errorProcessingRequest"))
	}

	return nil
}

// CampaignArchivesPage renders the public campaign archives page.
func (a *App) CampaignArchivesPage(c echo.Context) error {
	// Get archives from the DB.
	pg := a.pg.NewFromURL(c.Request().URL.Query())
	out, total, err := a.getCampaignArchives(pg.Offset, pg.Limit, false)
	if err != nil {
		return err
	}
	pg.SetTotal(total)

	title := a.i18n.T("public.archiveTitle")
	return c.Render(http.StatusOK, "archive", struct {
		Title       string
		Description string
		Campaigns   []campArchive
		TotalPages  int
		Pagination  template.HTML
	}{title, title, out, pg.TotalPages, template.HTML(pg.HTML("?page=%d"))})
}

// CampaignArchivePage renders the public campaign archives page.
func (a *App) CampaignArchivePage(c echo.Context) error {
	// ID can be the UUID or slug.
	var (
		idStr      = c.Param("id")
		uuid, slug string
	)
	if reUUID.MatchString(idStr) {
		uuid = idStr
	} else {
		slug = idStr
	}

	// Get the campaign from the DB.
	pubCamp, err := a.core.GetArchivedCampaign(0, uuid, slug)
	if err != nil || pubCamp.Type != models.CampaignTypeRegular {
		notFound := false

		// Camppaig doesn't exist.
		if er, ok := err.(*echo.HTTPError); ok {
			if er.Code == http.StatusBadRequest {
				notFound = true
			}
		} else if pubCamp.Type != models.CampaignTypeRegular {
			// Campaign isn't of regular type.
			notFound = true
		}

		// 404.
		if notFound {
			return c.Render(http.StatusNotFound, tplMessage,
				makeMsgTpl(a.i18n.T("public.notFoundTitle"), "", a.i18n.T("public.campaignNotFound")))
		}

		// Some other internal error.
		return c.Render(http.StatusInternalServerError, tplMessage,
			makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
	}

	// "Compile" the campaign template with appropriate data.
	out, err := a.compileArchiveCampaigns([]models.Campaign{pubCamp})
	if err != nil {
		return c.Render(http.StatusInternalServerError, tplMessage,
			makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
	}

	// Render the campaign body.
	camp := out[0].Campaign
	msg, err := a.manager.NewCampaignMessage(camp, out[0].Subscriber)
	if err != nil {
		a.log.Printf("error rendering campaign: %v", err)
		return c.Render(http.StatusInternalServerError, tplMessage,
			makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
	}

	return c.HTML(http.StatusOK, string(msg.Body()))
}

// CampaignArchivePageLatest renders the latest public campaign.
func (a *App) CampaignArchivePageLatest(c echo.Context) error {
	// Get the latest campaign from the DB.
	camps, _, err := a.getCampaignArchives(0, 1, true)
	if err != nil {
		return err
	}

	if len(camps) == 0 {
		return c.Render(http.StatusNotFound, tplMessage,
			makeMsgTpl(a.i18n.T("public.notFoundTitle"), "", a.i18n.T("public.campaignNotFound")))
	}
	camp := camps[0]

	return c.HTML(http.StatusOK, camp.Content)
}

// getCampaignArchives fetches the public campaign archives from the DB.
func (a *App) getCampaignArchives(offset, limit int, renderBody bool) ([]campArchive, int, error) {
	pubCamps, total, err := a.core.GetArchivedCampaigns(offset, limit)
	if err != nil {
		return []campArchive{}, total, echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("public.errorFetchingCampaign"))
	}

	msgs, err := a.compileArchiveCampaigns(pubCamps)
	if err != nil {
		return []campArchive{}, total, err
	}

	out := make([]campArchive, 0, len(msgs))
	for _, m := range msgs {
		camp := m.Campaign

		archive := campArchive{
			UUID:      camp.UUID,
			Subject:   camp.Subject,
			CreatedAt: camp.CreatedAt,
			SendAt:    camp.SendAt,
		}

		// The campaign may have a custom slug.
		if camp.ArchiveSlug.Valid {
			archive.URL, _ = url.JoinPath(a.urlCfg.ArchiveURL, camp.ArchiveSlug.String)
		} else {
			archive.URL, _ = url.JoinPath(a.urlCfg.ArchiveURL, camp.UUID)
		}

		// Render the full template body if requested.
		if renderBody {
			msg, err := a.manager.NewCampaignMessage(camp, m.Subscriber)
			if err != nil {
				return []campArchive{}, total, err
			}
			archive.Content = string(msg.Body())
		}

		out = append(out, archive)
	}

	return out, total, nil
}

// compileArchiveCampaigns compiles the campaign template with the subscriber data.
func (a *App) compileArchiveCampaigns(camps []models.Campaign) ([]manager.CampaignMessage, error) {

	var (
		b   = bytes.Buffer{}
		out = make([]manager.CampaignMessage, 0, len(camps))
	)
	for _, c := range camps {
		camp := c
		if err := camp.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil {
			a.log.Printf("error compiling template: %v", err)
			return nil, echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("public.errorFetchingCampaign"))
		}

		// Load the dummy subscriber meta.
		var sub models.Subscriber
		if err := json.Unmarshal([]byte(camp.ArchiveMeta), &sub); err != nil {
			a.log.Printf("error unmarshalling campaign archive meta: %v", err)
			return nil, echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("public.errorFetchingCampaign"))
		}

		m := manager.CampaignMessage{
			Campaign:   &camp,
			Subscriber: sub,
		}

		// Render the subject if it's a template.
		if camp.SubjectTpl != nil {
			if err := camp.SubjectTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
				return nil, err
			}
			camp.Subject = b.String()
			b.Reset()
		}

		out = append(out, m)
	}

	return out, nil
}


================================================
FILE: cmd/auth.go
================================================
package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"image/png"
	"net/http"
	"net/mail"
	"net/url"
	"strings"
	"time"

	"github.com/knadh/listmonk/internal/auth"
	"github.com/knadh/listmonk/internal/i18n"
	"github.com/knadh/listmonk/internal/notifs"
	"github.com/knadh/listmonk/internal/tmptokens"
	"github.com/knadh/listmonk/internal/utils"
	"github.com/knadh/listmonk/models"
	"github.com/labstack/echo/v4"
	"github.com/pquerna/otp/totp"
	"github.com/zerodha/simplesessions/v3"
	"gopkg.in/volatiletech/null.v6"
)

const (
	passwordResetTTL = 30 * time.Minute
	twofaTokenTTL    = 5 * time.Minute

	// Length of reset and 2FA auth tokens.
	tmpAuthTokenLen = 64
)

type loginTpl struct {
	Title       string
	Description string

	NextURI          string
	Nonce            string
	PasswordEnabled  bool
	OIDCProvider     string
	OIDCProviderLogo string
	Error            string
}

type oidcState struct {
	Nonce string `json:"nonce"`
	Next  string `json:"next"`
}

type forgotPasswordTpl struct {
	Title       string
	Description string
	Error       string
}

type resetPasswordTpl struct {
	Title       string
	Description string
	Token       string
	Email       string
	Error       string
}

type twofaTpl struct {
	Title       string
	Description string
	Token       string
	NextURI     string
	Error       string
}

var (
	oidcProviders = map[string]struct{}{
		"google.com":          {},
		"microsoftonline.com": {},
		"auth0.com":           {},
		"github.com":          {},
	}
)

// LoginPage renders the login page and handles the login form.
func (a *App) LoginPage(c echo.Context) error {
	// Has the user been setup?
	a.Lock()
	needsUserSetup := a.needsUserSetup
	a.Unlock()

	if needsUserSetup {
		return a.LoginSetupPage(c)
	}

	// Process POST login request.
	var loginErr error
	if c.Request().Method == http.MethodPost {
		loginErr = a.doLogin(c)
		if loginErr == nil {
			return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
		}
	}

	// Render the page, with or without POST.
	return a.renderLoginPage(c, loginErr)
}

// LoginSetupPage renders the first time user login page and handles the login form.
func (a *App) LoginSetupPage(c echo.Context) error {
	// Process POST login request.
	var loginErr error
	if c.Request().Method == http.MethodPost {
		loginErr = a.doFirstTimeSetup(c)
		if loginErr == nil {
			a.Lock()
			a.needsUserSetup = false
			a.Unlock()
			return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
		}
	}

	// Render the page, with or without POST.
	return a.renderLoginSetupPage(c, loginErr)
}

// TwofaPage renders the 2FA verification page and handles the 2FA form submission.
func (a *App) TwofaPage(c echo.Context) error {
	var token, next string

	if c.Request().Method == http.MethodPost {
		token = strings.TrimSpace(c.FormValue("token"))
		next = utils.SanitizeURI(c.FormValue("next"))
	} else {
		token = strings.TrimSpace(c.QueryParam("token"))
		next = utils.SanitizeURI(c.QueryParam("next"))
	}

	// If there's no token, redirect.
	if len(token) < tmpAuthTokenLen {
		return c.Redirect(http.StatusFound, uriAdmin)
	}

	if next == "" || next == "/" {
		next = uriAdmin
	}

	// Validate the 2FA temp token.
	data, err := tmptokens.Check(token)
	if err != nil {
		return c.Redirect(http.StatusFound, uriAdmin)
	}

	userID, ok := data.(int)
	if !ok {
		return a.renderTwofaPage(c, token, next, a.i18n.T("users.invalidRequest"))
	}

	// Process the 2FA verification POST request.
	if c.Request().Method == http.MethodPost {
		return a.doTwofaVerify(c, token, userID, next)
	}

	// Render the 2FA verification page.
	return a.renderTwofaPage(c, token, next, "")
}

// Logout logs a user out.
func (a *App) Logout(c echo.Context) error {
	// Delete the session from the DB and cookie.
	sess := c.Get(auth.SessionKey).(*simplesessions.Session)
	_ = sess.Destroy()

	return c.JSON(http.StatusOK, okResp{true})
}

// OIDCLogin initializes an OIDC request and redirects to the OIDC provider for login.
func (a *App) OIDCLogin(c echo.Context) error {
	// Verify that the request came from the login page (CSRF).
	nonce, err := c.Cookie("nonce")
	if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") {
		return echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest"))
	}

	// Sanitize the URL and make it relative.
	next := utils.SanitizeURI(c.FormValue("next"))
	if next == "/" {
		next = uriAdmin
	}

	// Preparethe OIDC payload to send to the provider.
	state := oidcState{Nonce: nonce.Value, Next: next}

	b, err := json.Marshal(state)
	if err != nil {
		a.log.Printf("error marshalling OIDC state: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}

	// Redirect to the external OIDC provider.
	return c.Redirect(http.StatusFound, a.auth.GetOIDCAuthURL(base64.URLEncoding.EncodeToString(b), nonce.Value))
}

// OIDCFinish receives the redirect callback from the OIDC provider and completes the handshake.
func (a *App) OIDCFinish(c echo.Context) error {
	// Verify that the request actually originated from the login request (which sets the nonce value).
	nonce, err := c.Cookie("nonce")
	if err != nil || nonce.Value == "" {
		return a.renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest")))
	}

	// Validate the OIDC token.
	oidcToken, claims, err := a.auth.ExchangeOIDCToken(c.Request().URL.Query().Get("code"), nonce.Value)
	if err != nil {
		return a.renderLoginPage(c, err)
	}

	// Validate the state.
	var state oidcState
	stateB, err := base64.URLEncoding.DecodeString(c.QueryParam("state"))
	if err != nil {
		a.log.Printf("error decoding OIDC state: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}
	if err := json.Unmarshal(stateB, &state); err != nil {
		a.log.Printf("error unmarshalling OIDC state: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}
	if state.Nonce != nonce.Value {
		return a.renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest")))
	}

	// Validate e-mail from the claim.
	email := strings.TrimSpace(claims.Email)
	if email == "" {
		return a.renderLoginPage(c, errors.New(a.i18n.Ts("globals.messages.invalidFields", "name", "email")))
	}
	em, err := mail.ParseAddress(email)
	if err != nil {
		return a.renderLoginPage(c, err)
	}
	email = strings.ToLower(em.Address)
	claims.Email = email

	// Get the user by e-mail received from OIDC.
	user, userErr := a.core.GetUser(0, "", email)
	if userErr != nil {
		// If the user doesn't exist, and auto-creation is enabled, create a new user.
		if httpErr, ok := userErr.(*echo.HTTPError); ok && httpErr.Code == http.StatusNotFound && a.cfg.Security.OIDC.AutoCreateUsers {
			u, err := a.createOIDCUser(claims, c)
			if err != nil {
				return a.renderLoginPage(c, err)
			}
			user = u
			userErr = nil
		} else {
			return a.renderLoginPage(c, userErr)
		}
	}

	// Update the user login state (avatar, logged in date) in the DB.
	if err := a.core.UpdateUserLogin(user.ID, claims.Picture); err != nil {
		return a.renderLoginPage(c, err)
	}

	// Set the session in the DB and cookie.
	if err := a.auth.SaveSession(user, oidcToken, c); err != nil {
		return a.renderLoginPage(c, err)
	}

	// Redirect to the next page.
	return c.Redirect(http.StatusFound, utils.SanitizeURI(state.Next))
}

// ForgotPage renders the forgot password page and handles the forgot password form.
func (a *App) ForgotPage(c echo.Context) error {
	// Process the forgot password request.
	if c.Request().Method == http.MethodPost {
		return a.doForgotPassword(c)
	}

	// Render the forgot page.
	out := forgotPasswordTpl{Title: a.i18n.T("users.forgotPassword")}
	return c.Render(http.StatusOK, "admin-forgot-password", out)
}

// ResetPage renders the reset password page and handles the reset password form.
func (a *App) ResetPage(c echo.Context) error {
	var (
		token = strings.TrimSpace(c.QueryParam("token"))
		email = strings.ToLower(strings.TrimSpace(c.QueryParam("email")))
	)

	// Validate token and email (don't delete it yet, as we may need it for POST).
	data, err := tmptokens.Check(email)
	if err != nil {
		return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
	}

	tk, ok := data.(string)
	if !ok || tk != token {
		return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
	}

	// Validate that the user exists.
	_, err = a.core.GetUser(0, "", email)
	if err != nil {
		return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
	}

	// Process the reset password request form with the new passwords.
	if c.Request().Method == http.MethodPost {
		return a.doResetPassword(c, token, email)
	}

	// Render the reset password form for GET request.
	return a.renderResetPasswordPage(c, token, email, "")
}

// renderLoginPage renders the login page and handles the login form.
func (a *App) renderLoginPage(c echo.Context, loginErr error) error {
	next := utils.SanitizeURI(c.FormValue("next"))
	if next == "/" {
		next = uriAdmin
	}

	var (
		oidcProviderName = ""
		oidcLogo         = ""
	)
	if a.cfg.Security.OIDC.Enabled {
		// Defaults.
		oidcProviderName = a.cfg.Security.OIDC.ProviderName
		oidcLogo = "oidc.png"

		u, err := url.Parse(a.cfg.Security.OIDC.ProviderURL)
		if err == nil {
			h := strings.Split(u.Hostname(), ".")

			// Get the last two h for the root domain
			prov := ""
			if len(h) >= 2 {
				prov = h[len(h)-2] + "." + h[len(h)-1]
			} else {
				prov = u.Hostname()
			}

			if oidcProviderName == "" {
				oidcProviderName = prov
			}

			// Lookup the logo in the known providers map.
			if _, ok := oidcProviders[prov]; ok {
				oidcLogo = prov + ".png"
			}
		}
	}

	out := loginTpl{
		Title:            a.i18n.T("users.login"),
		PasswordEnabled:  true,
		OIDCProvider:     oidcProviderName,
		OIDCProviderLogo: oidcLogo,
		NextURI:          next,
	}

	// If there was an error in the previous state (POST reqest), set it to render in the template.
	if loginErr != nil {
		if e, ok := loginErr.(*echo.HTTPError); ok {
			out.Error = e.Message.(string)
		} else {
			out.Error = loginErr.Error()
		}
	}

	// Generate and set a nonce for preventing CSRF requests that will be valided in the subsequent requests.
	nonce, err := utils.GenerateRandomString(16)
	if err != nil {
		a.log.Printf("error generating OIDC nonce: %v", err)
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.internalError"))
	}
	c.SetCookie(&http.Cookie{
		Name:     "nonce",
		Value:    nonce,
		HttpOnly: true,
		Path:     "/",
		SameSite: http.SameSiteLaxMode,
	})
	out.Nonce = nonce

	// Render the login page.
	return c.Render(http.StatusOK, "admin-login", out)
}

// renderLoginSetupPage renders the first time user setup page.
func (a *App) renderLoginSetupPage(c echo.Context, loginErr error) error {
	next := utils.SanitizeURI(c.FormValue("next"))
	if next == "/" {
		next = uriAdmin
	}

	out := loginTpl{
		Title:           a.i18n.T("users.login"),
		PasswordEnabled: true,
		NextURI:         next,
	}

	// If there was an error in the previous state (POST reqest), set it to render in the template.
	if loginErr != nil {
		if e, ok := loginErr.(*echo.HTTPError); ok {
			out.Error = e.Message.(string)
		} else {
			out.Error = loginErr.Error()
		}
	}

	return c.Render(http.StatusOK, "admin-login-setup", out)
}

// createOIDCUser creates a new user in the DB with the OIDC claims.
func (a *App) createOIDCUser(claims auth.OIDCclaim, c echo.Context) (auth.User, error) {
	name := claims.Name
	if name == "" {
		name = strings.TrimSpace(claims.PreferredUsername)
	}
	if name == "" {
		name = strings.Split(claims.Email, "@")[0]
	}

	var listRoleID *int
	if a.cfg.Security.OIDC.DefaultListRoleID > 0 {
		listRoleID = &a.cfg.Security.OIDC.DefaultListRoleID
	}

	user, err := a.core.CreateUser(auth.User{
		Type:          auth.UserTypeUser,
		HasPassword:   false,
		PasswordLogin: false,
		Username:      claims.Email,
		Name:          name,
		Email:         null.NewString(claims.Email, true),
		UserRoleID:    a.cfg.Security.OIDC.DefaultUserRoleID,
		ListRoleID:    listRoleID,
		Status:        auth.UserStatusEnabled,
	})

	return user, err
}

// doLogin logs a user in with a username and password.
func (a *App) doLogin(c echo.Context) error {
	var (
		startTime = time.Now()
		username  = strings.TrimSpace(c.FormValue("username"))
		password  = strings.TrimSpace(c.FormValue("password"))
	)

	// Ensure timing mitigation is applied regardless of early returns
	defer func() {
		if elapsed := time.Since(startTime).Milliseconds(); elapsed < 100 {
			time.Sleep(time.Duration(100-elapsed) * time.Millisecond)
		}
	}()

	if !strHasLen(username, 3, stdInputMaxLen) {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username"))
	}
	if !strHasLen(password, 8, stdInputMaxLen) {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
	}

	// Log the user in by fetching and verifying credentials from the DB.
	user, err := a.core.LoginUser(username, password)
	if err != nil {
		return err
	}

	// If TOTP is enabled for the user, create a temp token and redirect to the 2FA page.
	if user.TwofaType == models.TwofaTypeTOTP {
		// Generate a random token.
		token, err := generateRandomString(tmpAuthTokenLen)
		if err != nil {
			a.log.Printf("error generating 2FA token: %v", err)
			return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
		}

		// Set the token.
		tmptokens.Set(token, twofaTokenTTL, user.ID)

		// Redirect to 2FA page.
		next := utils.SanitizeURI(c.FormValue("next"))
		return c.Redirect(http.StatusFound, fmt.Sprintf("%s/login/twofa?token=%s&next=%s", uriAdmin, token, url.QueryEscape(next)))
	}

	// Set the session in the DB and cookie.
	if err := a.auth.SaveSession(user, "", c); err != nil {
		return err
	}

	return nil
}

// doFirstTimeSetup sets a user up for the first time.
func (a *App) doFirstTimeSetup(c echo.Context) error {
	var (
		email     = strings.TrimSpace(c.FormValue("email"))
		username  = strings.TrimSpace(c.FormValue("username"))
		password  = strings.TrimSpace(c.FormValue("password"))
		password2 = strings.TrimSpace(c.FormValue("password2"))
	)
	if !utils.ValidateEmail(email) {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "email"))
	}
	if !strHasLen(username, 3, stdInputMaxLen) {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username"))
	}
	if !strHasLen(password, 8, stdInputMaxLen) {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
	}
	if password != password2 {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.passwordMismatch"))
	}

	// Create the default "Super Admin" with all permissions if it doesn't exist.
	if _, err := a.core.GetRole(auth.SuperAdminRoleID); err != nil {
		r := auth.Role{
			Type: auth.RoleTypeUser,
			Name: null.NewString("Super Admin", true),
		}
		for p := range a.cfg.Permissions {
			r.Permissions = append(r.Permissions, p)
		}

		// Create the role in the DB.
		if _, err := a.core.CreateRole(r); err != nil {
			return err
		}
	}

	// Create the super admin user in the DB.
	u := auth.User{
		Type:          auth.UserTypeUser,
		HasPassword:   true,
		PasswordLogin: true,
		Username:      username,
		Name:          username,
		Password:      null.NewString(password, true),
		Email:         null.NewString(email, true),
		UserRoleID:    auth.SuperAdminRoleID,
		Status:        auth.UserStatusEnabled,
	}
	if _, err := a.core.CreateUser(u); err != nil {
		return err
	}

	// Log the user in directly.
	user, err := a.core.LoginUser(username, password)
	if err != nil {
		return err
	}

	// Set the session in the DB and cookie.
	if err := a.auth.SaveSession(user, "", c); err != nil {
		return err
	}

	return nil
}

// renderResetPasswordPage renders the reset password page.
func (a *App) renderResetPasswordPage(c echo.Context, token, email, errMsg string) error {
	out := resetPasswordTpl{
		Title: a.i18n.T("users.resetPassword"),
		Token: token,
		Email: email,
		Error: errMsg,
	}
	return c.Render(http.StatusOK, "admin-reset-password", out)
}

// doForgotPassword handles the forgot password form submission.
func (a *App) doForgotPassword(c echo.Context) error {
	var (
		email = strings.ToLower(strings.TrimSpace(c.FormValue("email")))
	)

	// Validate email format.
	if !utils.ValidateEmail(email) {
		return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
	}

	// Get the user by email.
	user, err := a.core.GetUser(0, "", email)
	if err != nil {
		return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
	}

	// If the password login is disabled, do not proceed, but show success message to prevent email enumeration.
	if !user.PasswordLogin {
		return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
	}

	// Generate a random token.
	token, err := generateRandomString(tmpAuthTokenLen)
	if err != nil {
		a.log.Printf("error generating reset token: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}

	// Store the reset token in tmptokens.
	tmptokens.Set(email, passwordResetTTL, token)

	// Prepare the reset URL.
	resetURL := fmt.Sprintf("%s/admin/reset?token=%s&email=%s", a.urlCfg.RootURL, token, url.QueryEscape(email))

	// Prepare the email.
	var msg bytes.Buffer
	data := struct {
		ResetURL string
		L        *i18n.I18n
	}{
		ResetURL: resetURL,
		L:        a.i18n,
	}

	// Render the email template.
	if err := notifs.Tpls.ExecuteTemplate(&msg, notifs.TplForgotPassword, data); err != nil {
		a.log.Printf("error compiling notification template '%s': %v", notifs.TplForgotPassword, err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}

	subject, body := notifs.GetTplSubject(a.i18n.T("email.forgotPassword.subject"), msg.Bytes())

	// Send the email.
	if err := a.emailMsgr.Push(models.Message{
		From:    a.cfg.FromEmail,
		To:      []string{email},
		Subject: subject,
		Body:    body,
	}); err != nil {
		a.log.Printf("error sending reset email: %s", err)
	}

	// Show the success e-mail nonetheless to prevent e-mail enumeration.
	return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
}

// doResetPassword handles the reset password form submission.
func (a *App) doResetPassword(c echo.Context, token, email string) error {
	var (
		password  = c.FormValue("password")
		password2 = c.FormValue("password2")
	)

	// Validate password.
	if !strHasLen(password, 8, stdInputMaxLen) {
		return a.renderResetPasswordPage(c, token, email, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
	}
	if password != password2 {
		return a.renderResetPasswordPage(c, token, email, a.i18n.T("users.passwordMismatch"))
	}

	// Validate and consume the token (this deletes it).
	data, err := tmptokens.Get(email)
	if err != nil {
		return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
	}

	tk, ok := data.(string)
	if !ok || tk != token {
		return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
	}

	// Get the user.
	user, err := a.core.GetUser(0, "", email)
	if err != nil {
		return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
	}

	// Password login is disabled for the user.
	if !user.PasswordLogin {
		return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("public.invalidFeature")))
	}

	user.Password = null.NewString(password, true)
	if _, err := a.core.UpdateUserProfile(user.ID, user); err != nil {
		a.log.Printf("error updating user password: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}

	// Log the user in directly without forcing a manual login right after password change.
	if err := a.auth.SaveSession(user, "", c); err != nil {
		return err
	}

	// Redirect to the admin page.
	return c.Redirect(http.StatusFound, uriAdmin)
}

// renderTwofaPage renders the 2FA verification page.
func (a *App) renderTwofaPage(c echo.Context, token, next, errMsg string) error {
	out := twofaTpl{
		Title:       a.i18n.T("users.twoFA"),
		Description: "",
		Token:       token,
		NextURI:     next,
		Error:       errMsg,
	}
	return c.Render(http.StatusOK, "admin-twofa", out)
}

// doTwofaVerify handles the 2FA verification form submission.
func (a *App) doTwofaVerify(c echo.Context, token string, userID int, next string) error {
	totpCode := strings.TrimSpace(c.FormValue("totp_code"))

	// Validate.
	if !strHasLen(totpCode, 6, 6) {
		return a.renderTwofaPage(c, token, next, a.i18n.T("globals.messages.invalidValue"))
	}

	// Get the user.
	user, err := a.core.GetUser(userID, "", "")
	if err != nil {
		return a.renderTwofaPage(c, token, next, a.i18n.T("users.invalidRequest"))
	}

	// Verify that TOTP is actually enabled for the user.
	if user.TwofaType != models.TwofaTypeTOTP {
		return a.renderTwofaPage(c, token, next, a.i18n.T("users.twoFANotEnabled"))
	}

	// Verify the TOTP code.
	valid := totp.Validate(totpCode, user.TwofaKey.String)
	if !valid {
		return a.renderTwofaPage(c, token, next, a.i18n.T("globals.messages.invalidValue"))
	}

	// Invalidate the token.
	tmptokens.Delete(token)

	// Set the session.
	if err := a.auth.SaveSession(user, "", c); err != nil {
		return err
	}

	// Redirect to the next page.
	return c.Redirect(http.StatusFound, next)
}

// GenerateTOTPQR generates a TOTP QR code for a user to scan with their authenticator app.
func (a *App) GenerateTOTPQR(c echo.Context) error {
	u := c.Get(auth.UserHTTPCtxKey).(auth.User)

	// If TOTP is already enabled, don't generate a new key.
	if u.TwofaType == models.TwofaTypeTOTP {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.twoFAAlreadyEnabled"))
	}

	// Generate a new TOTP key.
	key, err := totp.Generate(totp.GenerateOpts{
		Issuer:      a.cfg.SiteName,
		AccountName: u.Email.String,
	})
	if err != nil {
		a.log.Printf("error generating TOTP key: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}

	// Convert the TOTP key to a QR code image.
	img, err := key.Image(200, 200)
	if err != nil {
		a.log.Printf("error generating QR code: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}

	// Encode the QR code as a PNG and return it as base64.
	var buf bytes.Buffer
	if err := png.Encode(&buf, img); err != nil {
		a.log.Printf("error encoding QR code: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
	}

	return c.JSON(http.StatusOK, okResp{struct {
		Secret string `json:"secret"`
		QR     string `json:"qr"`
	}{
		Secret: key.Secret(),
		QR:     base64.StdEncoding.EncodeToString(buf.Bytes()),
	}})
}


================================================
FILE: cmd/bounce.go
================================================
package main

import (
	"encoding/json"
	"io"
	"net/http"
	"strconv"
	"time"

	"github.com/knadh/listmonk/models"
	"github.com/labstack/echo/v4"
)

// GetBounce handles retrieval of a specific bounce record by ID.
func (a *App) GetBounce(c echo.Context) error {
	// Fetch one bounce from the DB.
	id := getID(c)
	out, err := a.core.GetBounce(id)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// GetBounces handles retrieval of bounce records.
func (a *App) GetBounces(c echo.Context) error {
	var (
		campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
		source    = c.FormValue("source")
		orderBy   = c.FormValue("order_by")
		order     = c.FormValue("order")

		pg = a.pg.NewFromURL(c.Request().URL.Query())
	)

	// Query and fetch bounces from the DB.
	res, total, err := a.core.QueryBounces(campID, 0, source, orderBy, order, pg.Offset, pg.Limit)
	if err != nil {
		return err
	}

	// No results.
	if len(res) == 0 {
		return c.JSON(http.StatusOK, okResp{models.PageResults{Results: []models.Bounce{}}})
	}

	out := models.PageResults{
		Results: res,
		Total:   total,
		Page:    pg.Page,
		PerPage: pg.PerPage,
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// GetSubscriberBounces retrieves a subscriber's bounce records.
func (a *App) GetSubscriberBounces(c echo.Context) error {
	// Query and fetch bounces from the DB.
	subID := getID(c)
	out, _, err := a.core.QueryBounces(0, subID, "", "", "", 0, 1000)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// DeleteBounces handles bounce deletion of a list.
func (a *App) DeleteBounces(c echo.Context) error {
	all, _ := strconv.ParseBool(c.QueryParam("all"))

	var ids []int
	if !all {
		// There are multiple IDs in the query string.
		res, err := parseStringIDs(c.Request().URL.Query()["id"])
		if err != nil {
			return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidID", "error", err.Error()))
		}
		if len(res) == 0 {
			return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidID"))
		}

		ids = res
	}

	// Delete bounces from the DB.
	if err := a.core.DeleteBounces(ids, all); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{true})
}

// DeleteBounce handles bounce deletion of a single bounce record.
func (a *App) DeleteBounce(c echo.Context) error {
	// Delete bounces from the DB.
	id := getID(c)
	if err := a.core.DeleteBounces([]int{id}, false); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{true})
}

// BlocklistBouncedSubscribers handles blocklisting of all bounced subscribers.
func (a *App) BlocklistBouncedSubscribers(c echo.Context) error {
	if err := a.core.BlocklistBouncedSubscribers(); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{true})
}

// BounceWebhook renders the HTML preview of a template.
func (a *App) BounceWebhook(c echo.Context) error {
	// Read the request body instead of using c.Bind() to read to save the entire raw request as meta.
	rawReq, err := io.ReadAll(c.Request().Body)
	if err != nil {
		a.log.Printf("error reading ses notification body: %v", err)
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.internalError"))
	}

	var (
		service = c.Param("service")

		bounces []models.Bounce
	)
	switch true {
	// Native internal webhook.
	case service == "":
		var b models.Bounce
		if err := json.Unmarshal(rawReq, &b); err != nil {
			return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidData")+":"+err.Error())
		}

		if bv, err := a.validateBounceFields(b); err != nil {
			return err
		} else {
			b = bv
		}

		if len(b.Meta) == 0 {
			b.Meta = json.RawMessage("{}")
		}

		if b.CreatedAt.Year() == 0 {
			b.CreatedAt = time.Now()
		}

		bounces = append(bounces, b)

	// Amazon SES.
	case service == "ses" && a.cfg.BounceSESEnabled:
		switch c.Request().Header.Get("X-Amz-Sns-Message-Type") {
		// SNS webhook registration confirmation. Only after these are processed will the endpoint
		// start getting bounce notifications.
		case "SubscriptionConfirmation", "UnsubscribeConfirmation":
			if err := a.bounce.SES.ProcessSubscription(rawReq); err != nil {
				a.log.Printf("error processing SNS (SES) subscription: %v", err)
				return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
			}

		// Bounce notification.
		case "Notification":
			b, err := a.bounce.SES.ProcessBounce(rawReq)
			if err != nil {
				a.log.Printf("error processing SES notification: %v", err)
				return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
			}
			bounces = append(bounces, b)

		default:
			return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
		}

	// SendGrid.
	case service == "sendgrid" && a.cfg.BounceSendgridEnabled:
		var (
			sig = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Signature")
			ts  = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Timestamp")
		)

		// Sendgrid sends multiple bounces.
		bs, err := a.bounce.Sendgrid.ProcessBounce(sig, ts, rawReq)
		if err != nil {
			a.log.Printf("error processing sendgrid notification: %v", err)
			return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
		}
		bounces = append(bounces, bs...)

	// Postmark.
	case service == "postmark" && a.cfg.BouncePostmarkEnabled:
		bs, err := a.bounce.Postmark.ProcessBounce(rawReq, c)
		if err != nil {
			a.log.Printf("error processing postmark notification: %v", err)
			if _, ok := err.(*echo.HTTPError); ok {
				return err
			}

			return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
		}
		bounces = append(bounces, bs...)

	// ForwardEmail.
	case service == "forwardemail" && a.cfg.BounceForwardemailEnabled:
		var (
			sig = c.Request().Header.Get("X-Webhook-Signature")
		)

		bs, err := a.bounce.Forwardemail.ProcessBounce(sig, rawReq)
		if err != nil {
			a.log.Printf("error processing forwardemail notification: %v", err)
			if _, ok := err.(*echo.HTTPError); ok {
				return err
			}

			return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
		}
		bounces = append(bounces, bs...)

	default:
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("bounces.unknownService"))
	}

	// Insert bounces into the DB.
	for _, b := range bounces {
		if err := a.bounce.Record(b); err != nil {
			a.log.Printf("error recording bounce: %v", err)
		}
	}

	return c.JSON(http.StatusOK, okResp{true})
}

func (a *App) validateBounceFields(b models.Bounce) (models.Bounce, error) {
	if b.Email == "" && b.SubscriberUUID == "" {
		return b, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "email / subscriber_uuid"))
	}

	if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) {
		return b, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "subscriber_uuid"))
	}

	if b.Email != "" {
		em, err := a.importer.SanitizeEmail(b.Email)
		if err != nil {
			return b, echo.NewHTTPError(http.StatusBadRequest, err.Error())
		}
		b.Email = em
	}

	if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft && b.Type != models.BounceTypeComplaint {
		return b, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "type"))
	}

	return b, nil
}


================================================
FILE: cmd/campaigns.go
================================================
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/knadh/listmonk/internal/auth"
	"github.com/knadh/listmonk/internal/notifs"
	"github.com/knadh/listmonk/models"
	"github.com/labstack/echo/v4"
	"github.com/lib/pq"
	"gopkg.in/volatiletech/null.v6"
)

// campReq is a wrapper over the Campaign model for receiving
// campaign creation and update data from APIs.
type campReq struct {
	models.Campaign

	// This overrides Campaign.Lists to receive and
	// write a list of int IDs during creation and updation.
	// Campaign.Lists is JSONText for sending lists children
	// to the outside world.
	ListIDs []int `json:"lists"`

	MediaIDs []int `json:"media"`

	// This is only relevant to campaign test requests.
	SubscriberEmails pq.StringArray `json:"subscribers"`
}

// campContentReq wraps params coming from API requests for converting
// campaign content formats.
type campContentReq struct {
	models.Campaign
	From string `json:"from"`
	To   string `json:"to"`
}

var (
	reFromAddress = regexp.MustCompile(`((.+?)\s)?<(.+?)@(.+?)>`)
	reSlug        = regexp.MustCompile(`[^\p{L}\p{M}\p{N}]`)
)

// GetCampaigns handles retrieval of campaigns.
func (a *App) GetCampaigns(c echo.Context) error {
	// Get the authenticated user.
	user := auth.GetUser(c)

	var (
		hasAllPerm     = user.HasPerm(auth.PermCampaignsGetAll)
		permittedLists []int
	)

	if !hasAllPerm {
		// Either the user has campaigns:get_all permissions and can view all campaigns,
		// or the campaigns are filtered by the lists the user has get|manage access to.
		hasAllPerm, permittedLists = user.GetPermittedLists(auth.PermTypeGet | auth.PermTypeManage)
	}

	var (
		pg = a.pg.NewFromURL(c.Request().URL.Query())

		status    = c.QueryParams()["status"]
		tags      = c.QueryParams()["tag"]
		query     = strings.TrimSpace(c.FormValue("query"))
		orderBy   = c.FormValue("order_by")
		order     = c.FormValue("order")
		noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
	)

	// Query and retrieve campaigns from the DB.
	res, total, err := a.core.QueryCampaigns(query, status, tags, orderBy, order, hasAllPerm, permittedLists, pg.Offset, pg.Limit)
	if err != nil {
		return err
	}

	// Remove the body from the response if requested.
	if noBody {
		for i := range res {
			res[i].Body = ""
			res[i].BodySource.Valid = false
		}
	}

	// Paginate the response.
	if len(res) == 0 {
		return c.JSON(http.StatusOK, okResp{models.PageResults{Results: []models.Campaign{}}})
	}

	out := models.PageResults{
		Query:   query,
		Results: res,
		Total:   total,
		Page:    pg.Page,
		PerPage: pg.PerPage,
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// GetCampaign handles retrieval of campaigns.
func (a *App) GetCampaign(c echo.Context) error {
	// Get the campaign ID.
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeGet, id, c); err != nil {
		return err
	}

	// Get the campaign from the DB.
	out, err := a.core.GetCampaign(id, "", "")
	if err != nil {
		return err
	}

	// Blank out the body if requested.
	noBody, _ := strconv.ParseBool(c.QueryParam("no_body"))
	if noBody {
		out.Body = ""
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// PreviewCampaign renders the HTML preview of a campaign body.
func (a *App) PreviewCampaign(c echo.Context) error {
	// Get the campaign ID.
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeGet, id, c); err != nil {
		return err
	}

	var (
		isPost      = c.Request().Method == http.MethodPost
		contentType = c.FormValue("content_type")
		tplID, _    = strconv.Atoi(c.FormValue("template_id"))
	)
	// For visual content, template ID for previewing is irrelevant.
	if contentType == models.CampaignContentTypeVisual || tplID < 1 {
		tplID = 0
	}

	// Get the campaign from the DB for previewing with the `template_body` field.
	camp, err := a.core.GetCampaignForPreview(id, tplID)
	if err != nil {
		return err
	}

	// There's a body in the request to preview instead of the body in the DB.
	if isPost {
		camp.ContentType = contentType
		camp.Body = c.FormValue("body")

		// For visual campaigns, template body from the DB shouldn't be used.
		if contentType == models.CampaignContentTypeVisual {
			camp.TemplateBody = ""
		}
	}

	// Use a dummy campaign ID to prevent views and clicks from {{ TrackView }}
	// and {{ TrackLink }} being registered on preview.
	camp.UUID = dummySubscriber.UUID
	if err := camp.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil {
		a.log.Printf("error compiling template: %v", err)
		return echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("templates.errorCompiling", "error", err.Error()))
	}

	// Render the message body.
	msg, err := a.manager.NewCampaignMessage(&camp, dummySubscriber)
	if err != nil {
		a.log.Printf("error rendering message: %v", err)
		return echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("templates.errorRendering", "error", err.Error()))
	}

	// Plaintext headers for plain body.
	if camp.ContentType == models.CampaignContentTypePlain {
		return c.String(http.StatusOK, string(msg.Body()))
	}

	return c.HTML(http.StatusOK, string(msg.Body()))
}

// PreviewCampaignArchive renders the public campaign archives page.
func (a *App) PreviewCampaignArchive(c echo.Context) error {
	// Get the campaign ID.
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeGet, id, c); err != nil {
		return err
	}

	// Fetch the campaign body from the DB.
	tplID, _ := strconv.Atoi(c.FormValue("template_id"))
	camp, err := a.core.GetCampaignForPreview(id, tplID)
	if err != nil {
		return err
	}

	camp.ArchiveMeta = json.RawMessage([]byte(c.FormValue("archive_meta")))

	// "Compile" the campaign template with appropriate data.
	res, err := a.compileArchiveCampaigns([]models.Campaign{camp})
	if err != nil {
		return c.Render(http.StatusInternalServerError, tplMessage,
			makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
	}

	// Render the campaign body.
	out := res[0].Campaign
	msg, err := a.manager.NewCampaignMessage(out, res[0].Subscriber)
	if err != nil {
		a.log.Printf("error rendering campaign: %v", err)
		return c.Render(http.StatusInternalServerError, tplMessage,
			makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
	}

	return c.HTML(http.StatusOK, string(msg.Body()))
}

// CampaignContent handles campaign content (body) format conversions.
func (a *App) CampaignContent(c echo.Context) error {
	var camp campContentReq
	if err := c.Bind(&camp); err != nil {
		return err
	}

	// Convert formats, eg: markdown to HTML.
	out, err := camp.ConvertContent(camp.From, camp.To)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// CreateCampaign handles campaign creation.
// Newly created campaigns are always drafts.
func (a *App) CreateCampaign(c echo.Context) error {
	var o campReq
	if err := c.Bind(&o); err != nil {
		return err
	}

	// Filter lists against the current user's permitted lists.
	user := auth.GetUser(c)
	o.ListIDs = user.FilterListsByPerm(auth.PermTypeGet|auth.PermTypeManage, o.ListIDs)

	// If the campaign's 'opt-in', prepare a default message.
	switch o.Type {
	case models.CampaignTypeOptin:
		op, err := a.makeOptinCampaignMessage(o)
		if err != nil {
			return err
		}
		o = op
	case "":
		o.Type = models.CampaignTypeRegular
	}

	if o.Messenger == "" {
		o.Messenger = "email"
	}

	// Validate.
	if c, err := a.validateCampaignFields(o); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	} else {
		o = c
	}

	if o.ArchiveTemplateID.Valid && o.ArchiveTemplateID.Int != 0 {
		o.ArchiveTemplateID = o.TemplateID
	}

	out, err := a.core.CreateCampaign(o.Campaign, o.ListIDs, o.MediaIDs)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// UpdateCampaign handles campaign modification.
// Campaigns that are done cannot be modified.
func (a *App) UpdateCampaign(c echo.Context) error {
	// Get the campaign ID.
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
		return err
	}

	// Retrieve the campaign from the DB.
	cm, err := a.core.GetCampaign(id, "", "")
	if err != nil {
		return err
	}

	if !canEditCampaign(cm.Status) {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.cantUpdate"))
	}

	// Clear attribs to avoid merging old and new values as json.Unmarshal in JSON.scan() merges maps,
	// merging values already in the DB and incoming values. If this is nil, then DB values remain
	// unchanged.
	cm.Attribs = nil

	// Read the incoming params into the existing campaign fields from the DB.
	// This allows updating of values that have been sent whereas fields
	// that are not in the request retain the old values.
	o := campReq{Campaign: cm}
	if err := c.Bind(&o); err != nil {
		return err
	}

	if c, err := a.validateCampaignFields(o); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	} else {
		o = c
	}

	out, err := a.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.MediaIDs)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// UpdateCampaignStatus handles campaign status modification.
func (a *App) UpdateCampaignStatus(c echo.Context) error {
	// Get the campaign ID.
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
		return err
	}

	req := struct {
		Status string `json:"status"`
	}{}
	if err := c.Bind(&req); err != nil {
		return err
	}

	// Update the campaign status in the DB.
	out, err := a.core.UpdateCampaignStatus(id, req.Status)
	if err != nil {
		return err
	}

	// If the campaign is being stopped, send the signal to the manager to stop it in flight.
	if req.Status == models.CampaignStatusPaused || req.Status == models.CampaignStatusCancelled {
		a.manager.StopCampaign(id)
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// UpdateCampaignArchive handles campaign status modification.
func (a *App) UpdateCampaignArchive(c echo.Context) error {
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
		return err
	}

	req := struct {
		Archive     bool        `json:"archive"`
		TemplateID  int         `json:"archive_template_id"`
		Meta        models.JSON `json:"archive_meta"`
		ArchiveSlug string      `json:"archive_slug"`
	}{}
	if err := c.Bind(&req); err != nil {
		return err
	}

	if req.ArchiveSlug != "" {
		// Format the slug to be alpha-numeric-dash.
		s := strings.ToLower(req.ArchiveSlug)
		s = strings.TrimSpace(reSlug.ReplaceAllString(s, " "))
		s = regexpSpaces.ReplaceAllString(s, "-")
		req.ArchiveSlug = s
	}

	if err := a.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta, req.ArchiveSlug); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{req})
}

// DeleteCampaign handles campaign deletion.
// Only scheduled campaigns that have not started yet can be deleted.
func (a *App) DeleteCampaign(c echo.Context) error {
	// Get the campaign ID.
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
		return err
	}

	// Delete the campaign from the DB.
	if err := a.core.DeleteCampaign(id); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{true})
}

// DeleteCampaigns deletes multiple campaigns by IDs or by query.
func (a *App) DeleteCampaigns(c echo.Context) error {
	// Get the authenticated user.
	user := auth.GetUser(c)

	var (
		hasAllPerm     = user.HasPerm(auth.PermCampaignsManageAll)
		permittedLists []int
	)

	if !hasAllPerm {
		// Either the user has campaigns:manage_all permissions and can manage all campaigns,
		// or the campaigns are filtered by the lists the user has get|manage access to.
		hasAllPerm, permittedLists = user.GetPermittedLists(auth.PermTypeGet | auth.PermTypeManage)
	}

	var (
		ids   []int
		query string
		all   bool
	)

	// Check for IDs in query params.
	if len(c.Request().URL.Query()["id"]) > 0 {
		var err error
		ids, err = parseStringIDs(c.Request().URL.Query()["id"])
		if err != nil {
			return echo.NewHTTPError(http.StatusBadRequest,
				a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
		}
	} else {
		// Check for query param.
		query = strings.TrimSpace(c.FormValue("query"))
		all = c.FormValue("all") == "true"
	}

	// Validate that either IDs or query is provided.
	if len(ids) == 0 && (query == "" && !all) {
		return echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("globals.messages.errorInvalidIDs", "error", "id or query required"))
	}

	// Delete the campaigns from the DB.
	if err := a.core.DeleteCampaigns(ids, query, hasAllPerm, permittedLists); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{true})
}

// GetRunningCampaignStats returns stats of a given set of campaign IDs.
func (a *App) GetRunningCampaignStats(c echo.Context) error {
	// Get the running campaign stats from the DB.
	out, err := a.core.GetRunningCampaignStats()
	if err != nil {
		return err
	}

	if len(out) == 0 {
		return c.JSON(http.StatusOK, okResp{[]struct{}{}})
	}

	// Compute rate.
	for i, c := range out {
		if c.Started.Valid && c.UpdatedAt.Valid {
			diff := max(int(c.UpdatedAt.Time.Sub(c.Started.Time).Minutes()), 1)

			rate := c.Sent / diff
			if rate > c.Sent || rate > c.ToSend {
				rate = c.Sent
			}

			// Rate since the starting of the campaign.
			out[i].NetRate = rate

			// Realtime running rate over the last minute.
			out[i].Rate = a.manager.GetCampaignStats(c.ID).SendRate
		}
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// TestCampaign handles the sending of a campaign message to
// arbitrary subscribers for testing.
func (a *App) TestCampaign(c echo.Context) error {
	// Get the campaign ID.
	id := getID(c)

	// Check if the user has access to the campaign.
	if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
		return err
	}

	// Get and validate fields.
	var req campReq
	if err := c.Bind(&req); err != nil {
		return err
	}

	// Validate.
	if c, err := a.validateCampaignFields(req); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	} else {
		req = c
	}
	if len(req.SubscriberEmails) == 0 {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.noSubsToTest"))
	}

	// Sanitize subscriber e-mails.
	for i := range req.SubscriberEmails {
		req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i]))
	}

	// Get the subscribers from the DB by their e-mails.
	subs, err := a.core.GetSubscribersByEmail(req.SubscriberEmails)
	if err != nil {
		return err
	}

	// Get the campaign from the DB for previewing.
	tplID, _ := strconv.Atoi(c.FormValue("template_id"))
	camp, err := a.core.GetCampaignForPreview(id, tplID)
	if err != nil {
		return err
	}

	// Override certain values from the DB with incoming values.
	camp.Name = req.Name
	camp.Subject = req.Subject
	camp.FromEmail = req.FromEmail
	camp.Body = req.Body
	camp.AltBody = req.AltBody
	camp.Messenger = req.Messenger
	camp.ContentType = req.ContentType
	camp.Headers = req.Headers
	camp.TemplateID = req.TemplateID
	for _, id := range req.MediaIDs {
		if id > 0 {
			camp.MediaIDs = append(camp.MediaIDs, int64(id))
		}
	}

	// Send the test messages.
	for _, s := range subs {
		sub := s

		if err := a.sendTestMessage(sub, &camp); err != nil {
			a.log.Printf("error sending test message: %v", err)
			return echo.NewHTTPError(http.StatusInternalServerError,
				a.i18n.Ts("campaigns.errorSendTest", "error", err.Error()))
		}
	}

	return c.JSON(http.StatusOK, okResp{true})
}

// GetCampaignViewAnalytics retrieves view counts for a campaign.
func (a *App) GetCampaignViewAnalytics(c echo.Context) error {
	ids, err := parseStringIDs(c.Request().URL.Query()["id"])
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
	}

	if len(ids) == 0 {
		return echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
	}

	var (
		typ  = c.Param("type")
		from = c.QueryParams().Get("from")
		to   = c.QueryParams().Get("to")
	)
	if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("analytics.invalidDates"))
	}

	// Campaign link stats.
	if typ == "links" {
		out, err := a.core.GetCampaignAnalyticsLinks(ids, typ, from, to)
		if err != nil {
			return err
		}

		return c.JSON(http.StatusOK, okResp{out})
	}

	// Get the analytics numbers from the DB for the campaigns.
	out, err := a.core.GetCampaignAnalyticsCounts(ids, typ, from, to)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, okResp{out})
}

// sendTestMessage takes a campaign and a subscriber and sends out a sample campaign message.
func (a *App) sendTestMessage(sub models.Subscriber, camp *models.Campaign) error {
	if err := camp.CompileTemplate(a.manager.TemplateFuncs(camp)); err != nil {
		a.log.Printf("error compiling template: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError,
			a.i18n.Ts("templates.errorCompiling", "error", err.Error()))
	}

	// Create a sample campaign message.
	msg, err := a.manager.NewCampaignMessage(camp, sub)
	if err != nil {
		a.log.Printf("error rendering message: %v", err)
		return echo.NewHTTPError(http.StatusNotFound, a.i18n.Ts("templates.errorRendering", "error", err.Error()))
	}

	return a.manager.PushCampaignMessage(msg)
}

// validateCampaignFields validates incoming campaign field values.
func (a *App) validateCampaignFields(c campReq) (campReq, error) {
	if c.FromEmail == "" {
		c.FromEmail = a.cfg.FromEmail
	} else if !reFromAddress.Match([]byte(c.FromEmail)) {
		if _, err := a.importer.SanitizeEmail(c.FromEmail); err != nil {
			return c, errors.New(a.i18n.T("campaigns.fieldInvalidFromEmail"))
		}
	}

	if !strHasLen(c.Name, 1, stdInputMaxLen) {
		return c, errors.New(a.i18n.T("campaigns.fieldInvalidName"))
	}

	// Larger char limit for subject as it can contain {{ go templating }} logic.
	if !strHasLen(c.Subject, 1, 5000) {
		return c, errors.New(a.i18n.T("campaigns.fieldInvalidSubject"))
	}

	// If no content-type is specified, default to richtext.
	if c.ContentType != models.CampaignContentTypeRichtext &&
		c.ContentType != models.CampaignContentTypeHTML &&
		c.ContentType != models.CampaignContentTypePlain &&
		c.ContentType != models.CampaignContentTypeVisual &&
		c.ContentType != models.CampaignContentTypeMarkdown {
		c.ContentType = models.CampaignContentTypeRichtext
	}

	if c.ContentType != models.CampaignContentTypeVisual {
		c.BodySource.Valid = false
	}

	// If there's a "send_at" date, it should be in the future.
	if c.SendAt.Valid {
		if c.SendAt.Time.Before(time.Now()) {
			return c, errors.New(a.i18n.T("campaigns.fieldInvalidSendAt"))
		}
	}

	if len(c.ListIDs) == 0 {
		return c, errors.New(a.i18n.T("campaigns.fieldInvalidListIDs"))
	}

	if !a.manager.HasMessenger(c.Messenger) {
		// If it's a specific SMTP, but it's no longer available (removed/disabled), fall back to general email messenger.
		if strings.HasPrefix(c.Messenger, "email-") {
			c.Messenger = "email"
		} else {
			return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidMessenger", "name", c.Messenger))
		}
	}

	camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
	if err := c.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil {
		return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
	}

	if len(c.Headers) == 0 {
		c.Headers = make([]map[string]string, 0)
	}

	// Validate and initialize attribs.
	if c.Attribs != nil {
		if _, err := json.Marshal(c.Attribs); err != nil {
			return c, errors.New(a.i18n.T("subscribers.invalidJSON"))
		}
	}

	if len(c.ArchiveMeta) == 0 {
		c.ArchiveMeta = json.RawMessage("{}")
	}

	if c.ArchiveSlug.String != "" {
		// Format the slug to be alpha-numeric-dash.
		s := strings.ToLower(c.ArchiveSlug.String)
		s = strings.TrimSpace(reSlug.ReplaceAllString(s, " "))
		s = regexpSpaces.ReplaceAllString(s, "-")

		c.ArchiveSlug = null.NewString(s, true)
	} else {
		// If there's no slug set, set it to NULL in the DB.
		c.ArchiveSlug.Valid = false
	}

	return c, nil
}

// makeOptinCampaignMessage makes a default opt-in campaign message body.
func (a *App) makeOptinCampaignMessage(o campReq) (campReq, error) {
	if len(o.ListIDs) == 0 {
		return o, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.fieldInvalidListIDs"))
	}

	// Fetch double opt-in lists from the given list IDs from the DB.
	lists, err := a.core.GetListsByOptin(o.ListIDs, models.ListOptinDouble)
	if err != nil {
		return o, err
	}

	// There are no double opt-in lists.
	if len(lists) == 0 {
		return o, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.noOptinLists"))
	}

	// Construct the opt-in URL with list IDs.
	listIDs := url.Values{}
	for _, l := range lists {
		listIDs.Add("l", l.UUID)
	}
	// optinURLFunc := template.URL("{{ OptinURL }}?" + listIDs.Encode())
	optinURLAttr := template.HTMLAttr(fmt.Sprintf(`href="{{ OptinURL }}%s"`, listIDs.Encode()))

	// Prepare sample opt-in message for the campaign.
	var b bytes.Buffer

	if err := notifs.Tpls.ExecuteTemplate(&b, "optin-campaign", struct {
		Lists        []models.List
		OptinURLAttr template.HTMLAttr
	}{lists, optinURLAttr}); err != nil {
		a.log.Printf("error compiling 'optin-campaign' template: %v", err)
		return o, echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("templates.errorCompiling", "error", err.Error()))
	}

	o.Body = b.String()
	return o, nil
}

// checkCampaignPerm checks if the user has get or manage access to the given campaign.
// Either the user has blanket get_all/manage_all permissions, or the campaign
// belongs to lists that the user has access to.
func (a *App) checkCampaignPerm(types auth.PermType, id int, c echo.Context) error {
	// Get the authenticated user.
	user := auth.GetUser(c)

	perm := auth.PermCampaignsGet
	if types&auth.PermTypeGet != 0 {
		// It's a get request and there's a blanket get all permission.
		if user.HasPerm(auth.PermCampaignsGetAll) {
			return nil
		}
	} else {
		// It's a manage request and there's a blanket manage_all permission.
		if user.HasPerm(auth.PermCampaignsManageAll) {
			return nil
		}

		perm = auth.PermCampaignsManage
	}

	// There are no *_all campaign permissions. Instead, check if the user access
	// blanket get_all/manage_all list permissions. If yes, then the user can access
	// all campaigns. If there are no *_all permissions, then ensure that the
	// campaign belongs to the lists that the user has access to.
	if hasAllPerm, permittedListIDs := user.GetPermittedLists(auth.PermTypeGet | auth.PermTypeManage); !hasAllPerm {
		if ok, err := a.core.CampaignHasLists(id, permittedListIDs); err != nil {
			return err
		} else if !ok {
			return echo.NewHTTPError(http.StatusForbidden,
				a.i18n.Ts("globals.messages.permissionDenied", "name", perm))
		}
	}

	return nil
}

// canEditCampaign returns true if a campaign is in a status where updating
// its properties is allowed.
func canEditCampaign(status string) bool {
	return status == models.CampaignStatusDraft ||
		status == models.CampaignStatusPaused ||
		status == models.CampaignStatusScheduled
}


================================================
FILE: cmd/events.go
================================================
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"time"

	"github.com/labstack/echo/v4"
)

// EventStream serves an endpoint that never closes and pushes a
// live event stream (text/event-stream) such as a error messages.
func (a *App) EventStream(c echo.Context) error {
	hdr := c.Response().Header()
	hdr.Set(echo.HeaderContentType, "text/event-stream")
	hdr.Set(echo.HeaderCacheControl, "no-store")
	hdr.Set(echo.HeaderConnection, "keep-alive")

	// Subscribe to the event stream with a random ID.
	id := fmt.Sprintf("api:%v", time.Now().UnixNano())
	sub, err := a.events.Subscribe(id)
	if err != nil {
		log.Fatalf("error subscribing to events: %v", err)
	}

	ctx := c.Request().Context()
	for {
		select {
		case e := <-sub:
			b, err := json.Marshal(e)
			if err != nil {
				a.log.Printf("error marshalling event: %v", err)
				continue
			}

			c.Response().Write([]byte(fmt.Sprintf("retry: 3000\ndata: %s\n\n", b)))
			c.Response().Flush()

		case <-ctx.Done():
			// On HTTP connection close, unsubscribe.
			a.events.Unsubscribe(id)
			return nil
		}
	}

}


================================================
FILE: cmd/handlers.go
================================================
package main

import (
	"bytes"
	"net/http"
	"net/url"
	"path"
	"regexp"
	"strconv"

	"github.com/knadh/listmonk/internal/auth"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

const (
	// stdInputMaxLen is the maximum allowed length for a standard input field.
	stdInputMaxLen = 2000

	// URIs.
	uriAdmin = "/admin"
)

type okResp struct {
	Data any `json:"data"`
}

var (
	reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
)

// registerHandlers registers HTTP handlers.
func initHTTPHandlers(e *echo.Echo, a *App) {
	// Default error handler.
	e.HTTPErrorHandler = func(err error, c echo.Context) {
		// Generic, non-echo error. Log it.
		if _, ok := err.(*echo.HTTPError); !ok {
			a.log.Println(err.Error())
		}
		e.DefaultHTTPErrorHandler(err, c)
	}

	// Configure CORS middleware if domains are configured.
	if len(a.cfg.Security.CorsOrigins) > 0 {
		e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
			AllowOrigins: a.cfg.Security.CorsOrigins,
			AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
		}))
	}

	// =================================================================
	// Authenticated non /api handlers.
	{
		// Attach a middleware to the group that checks for auth.
		g := e.Group("", a.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc {
			return func(c echo.Context) error {
				u := c.Get(auth.UserHTTPCtxKey)

				// On no-auth, redirect to login page
				if _, ok := u.(*echo.HTTPError); ok {
					u, _ := url.Parse(a.urlCfg.LoginURL)
					q := url.Values{}
					q.Set("next", c.Request().RequestURI)
					u.RawQuery = q.Encode()
					return c.Redirect(http.StatusTemporaryRedirect, u.String())
				}

				return next(c)
			}
		})

		// Authenticated endpoints.
		g.GET(path.Join(uriAdmin, ""), a.AdminPage)
		g.GET(path.Join(uriAdmin, "/custom.css"), serveCustomAppearance("admin.custom_css"))
		g.GET(path.Join(uriAdmin, "/custom.js"), serveCustomAppearance("admin.custom_js"))
		g.GET(path.Join(uriAdmin, "/*"), a.AdminPage)
	}

	// =================================================================
	// Authenticated /api/* handlers.
	{
		var (
			// Permission check middleware.
			pm = a.auth.Perm

			// Attach a middleware to the group that checks for auth.
			g = e.Group("", a.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc {
				return func(c echo.Context) error {
					u := c.Get(auth.UserHTTPCtxKey)

					// On no-auth, respond with a JSON error.
					if err, ok := u.(*echo.HTTPError); ok {
						return err
					}

					return next(c)
				}
			})
		)

		// API endpoints.
		g.GET("/api/health", a.HealthCheck)
		g.GET("/api/config", a.GetServerConfig)
		g.GET("/api/lang/:lang", a.GetI18nLang)
		g.GET("/api/dashboard/charts", a.GetDashboardCharts)
		g.GET("/api/dashboard/counts", a.GetDashboardCounts)

		g.GET("/api/settings", pm(a.GetSettings, "settings:get"))
		g.PUT("/api/settings", pm(a.UpdateSettings, "settings:manage"))
		g.PUT("/api/settings/:key", pm(a.UpdateSettingsByKey, "settings:manage"))
		g.POST("/api/settings/smtp/test", pm(a.TestSMTPSettings, "settings:manage"))
		g.POST("/api/admin/reload", pm(a.ReloadApp, "settings:manage"))
		g.GET("/api/logs", pm(a.GetLogs, "settings:get"))
		g.GET("/api/events", pm(a.EventStream, "settings:get"))
		g.GET("/api/about", a.GetAboutInfo)

		g.GET("/api/subscribers", pm(a.QuerySubscribers, "subscribers:get_all", "subscribers:get"))
		g.GET("/api/subscribers/:id", pm(hasID(a.GetSubscriber), "subscribers:get_all", "subscribers:get"))
		g.GET("/api/subscribers/:id/activity", pm(hasID(a.GetSubscriberActivity), "subscribers:get_all", "subscribers:get"))
		g.GET("/api/subscribers/:id/export", pm(hasID(a.ExportSubscriberData), "subscribers:get_all", "subscribers:get"))
		g.GET("/api/subscribers/:id/bounces", pm(hasID(a.GetSubscriberBounces), "bounces:get"))
		g.DELETE("/api/subscribers/:id/bounces", pm(hasID(a.DeleteSubscriberBounces), "bounces:manage"))
		g.POST("/api/subscribers", pm(a.CreateSubscriber, "subscribers:manage"))
		g.PUT("/api/subscribers/:id", pm(hasID(a.UpdateSubscriber), "subscribers:manage"))
		g.POST("/api/subscribers/:id/optin", pm(hasID(a.SubscriberSendOptin), "subscribers:manage"))
		g.PUT("/api/subscribers/blocklist", pm(a.BlocklistSubscribers, "subscribers:manage"))
		g.PUT("/api/subscribers/:id/blocklist", pm(hasID(a.BlocklistSubscriber), "subscribers:manage"))
		g.PUT("/api/subscribers/lists/:id", pm(a.ManageSubscriberLists, "subscribers:manage"))
		g.PUT("/api/subscribers/lists", pm(a.ManageSubscriberLists, "subscribers:manage"))
		g.DELETE("/api/subscribers/:id", pm(hasID(a.DeleteSubscriber), "subscribers:manage"))
		g.DELETE("/api/subscribers", pm(a.DeleteSubscribers, "subscribers:manage"))

		g.GET("/api/bounces", pm(a.GetBounces, "bounces:get"))
		g.PUT("/api/bounces/blocklist", pm(a.BlocklistBouncedSubscribers, "bounces:manage"))
		g.GET("/api/bounces/:id", pm(hasID(a.GetBounce), "bounces:get"))
		g.DELETE("/api/bounces", pm(a.DeleteBounces, "bounces:manage"))
		g.DELETE("/api/bounces/:id", pm(hasID(a.DeleteBounce), "bounces:manage"))

		// Subscriber operations based on arbitrary SQL queries.
		// These aren't very REST-like.
		g.POST("/api/subscribers/query/delete", pm(a.DeleteSubscribersByQuery, "subscribers:manage"))
		g.PUT("/api/subscribers/query/blocklist", pm(a.BlocklistSubscribersByQuery, "subscribers:manage"))
		g.PUT("/api/subscribers/query/lists", pm(a.ManageSubscriberListsByQuery, "subscribers:manage"))
		g.GET("/api/subscribers/export",
			pm(middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(a.ExportSubscribers), "subscribers:get_all", "subscribers:get"))

		g.GET("/api/import/subscribers", pm(a.GetImportSubscribers, "subscribers:import"))
		g.GET("/api/import/subscribers/logs", pm(a.GetImportSubscriberStats, "subscribers:import"))
		g.POST("/api/import/subscribers", pm(a.ImportSubscribers, "subscribers:import"))
		g.DELETE("/api/import/subscribers", pm(a.StopImportSubscribers, "subscribers:import"))

		// Individual list permissions are applied directly within handleGetLists.
		g.GET("/api/lists", a.GetLists)
		g.GET("/api/lists/:id", hasID(a.GetList))
		g.POST("/api/lists", pm(a.CreateList, "lists:manage_all"))
		g.PUT("/api/lists/:id", hasID(a.UpdateList))
		g.DELETE("/api/lists", a.DeleteLists)
		g.DELETE("/api/lists/:id", hasID(a.DeleteList))

		g.GET("/api/campaigns", pm(a.GetCampaigns, "campaigns:get_all", "campaigns:get"))
		g.GET("/api/campaigns/running/stats", pm(a.GetRunningCampaignStats, "campaigns:get_all", "campaigns:get"))
		g.GET("/api/campaigns/:id", pm(hasID(a.GetCampaign), "campaigns:get_all", "campaigns:get"))
		g.GET("/api/campaigns/analytics/:type", pm(a.GetCampaignViewAnalytics, "campaigns:get_analytics"))
		g.GET("/api/campaigns/:id/preview", pm(hasID(a.PreviewCampaign), "campaigns:get_all", "campaigns:get"))
		g.POST("/api/campaigns/:id/preview/archive", pm(hasID(a.PreviewCampaignArchive), "campaigns:get_all", "campaigns:get"))
		g.POST("/api/campaigns/:id/preview", pm(hasID(a.PreviewCampaign), "campaigns:get_all", "campaigns:get"))
		g.POST("/api/campaigns/:id/content", pm(hasID(a.CampaignContent), "campaigns:manage_all", "campaigns:manage"))
		g.POST("/api/campaigns/:id/text", pm(hasID(a.PreviewCampaign), "campaigns:get"))
		g.POST("/api/campaigns/:id/test", pm(hasID(a.TestCampaign), "campaigns:manage_all", "campaigns:manage"))
		g.POST("/api/campaigns", pm(a.CreateCampaign, "campaigns:manage_all", "campaigns:manage"))
		g.PUT("/api/campaigns/:id", pm(hasID(a.UpdateCampaign), "campaigns:manage_all", "campaigns:manage"))
		g.PUT("/api/campaigns/:id/status", pm(hasID(a.UpdateCampaignStatus), "campaigns:manage_all", "campaigns:manage"))
		g.PUT("/api/campaigns/:id/archive", pm(hasID(a.UpdateCampaignArchive), "campaigns:manage_all", "campaigns:manage"))
		g.DELETE("/api/campaigns", pm(a.DeleteCampaigns, "campaigns:manage", "campaigns:manage_all"))
		g.DELETE("/api/campaigns/:id", pm(hasID(a.DeleteCampaign), "campaigns:manage_all", "campaigns:manage"))

		g.GET("/api/media", pm(a.GetAllMedia, "media:get"))
		g.GET("/api/media/:id", pm(hasID(a.GetMedia), "media:get"))
		g.POST("/api/media", pm(a.UploadMedia, "media:manage"))
		g.DELETE("/api/media/:id", pm(hasID(a.DeleteMedia), "media:manage"))

		g.GET("/api/templates", pm(a.GetTemplates, "templates:get"))
		g.GET("/api/templates/:id", pm(hasID(a.GetTemplate), "templates:get"))
		g.GET("/api/templates/:id/preview", pm(hasID(a.PreviewTemplate), "templates:get"))
		g.POST("/api/templates/preview", pm(a.PreviewTemplateBody, "templates:get"))
		g.POST("/api/templates", pm(a.CreateTemplate, "templates:manage"))
		g.PUT("/api/templates/:id", pm(hasID(a.UpdateTemplate), "templates:manage"))
		g.PUT("/api/templates/:id/default", pm(hasID(a.TemplateSetDefault), "templates:manage"))
		g.DELETE("/api/templates/:id", pm(hasID(a.DeleteTemplate), "templates:manage"))

		g.DELETE("/api/maintenance/subscribers/:type", pm(a.GCSubscribers, "settings:maintain"))
		g.DELETE("/api/maintenance/analytics/:type", pm(a.GCCampaignAnalytics, "settings:maintain"))
		g.DELETE("/api/maintenance/subscriptions/unconfirmed", pm(a.GCSubscriptions, "settings:maintain"))

		g.POST("/api/tx", pm(a.SendTxMessage, "tx:send"))

		g.GET("/api/profile", a.GetUserProfile)
		g.PUT("/api/profile", a.UpdateUserProfile)
		g.GET("/api/users", pm(a.GetUsers, "users:get"))
		g.GET("/api/users/:id", pm(hasID(a.GetUser), "users:get"))
		g.POST("/api/users", pm(a.CreateUser, "users:manage"))
		g.PUT("/api/users/:id", pm(hasID(a.UpdateUser), "users:manage"))
		g.DELETE("/api/users", pm(a.DeleteUsers, "users:manage"))
		g.DELETE("/api/users/:id", pm(hasID(a.DeleteUser), "users:manage"))
		g.POST("/api/logout", a.Logout)

		// TOTP 2FA endpoints
		g.GET("/api/users/:id/twofa/totp", hasID(a.GenerateTOTPQR))
		g.PUT("/api/users/:id/twofa", hasID(a.EnableTOTP))
		g.DELETE("/api/users/:id/twofa", hasID(a.DisableTOTP))

		g.GET("/api/roles/users", pm(a.GetUserRoles, "roles:get"))
		g.GET("/api/roles/lists", pm(a.GeListRoles, "roles:get"))
		g.POST("/api/roles/users", pm(a.CreateUserRole, "roles:manage"))
		g.POST("/api/roles/lists", pm(a.CreateListRole, "roles:manage"))
		g.PUT("/api/roles/users/:id", pm(hasID(a.UpdateUserRole), "roles:manage"))
		g.PUT("/api/roles/lists/:id", pm(hasID(a.UpdateListRole), "roles:manage"))
		g.DELETE("/api/roles/:id", pm(hasID(a.DeleteRole), "roles:manage"))

		if a.cfg.BounceWebhooksEnabled {
			// Private authenticated bounce endpoint.
			g.POST("/webhooks/bounce", pm(a.BounceWebhook, "webhooks:post_bounce"))
		}
	}

	// =================================================================
	// Public API endpoints.
	{
		// Public unauthenticated endpoints.
		g := e.Group("")

		if a.cfg.BounceWebhooksEnabled {
			// Public bounce endpoints for webservices like SES.
			g.POST("/webhooks/service/:service", a.BounceWebhook)
		}

		// Landing page.
		g.GET("/", func(c echo.Context) error {
			return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"})
		})

		// Public admin endpoints (login page, OIDC endpoints, password reset).
		g.GET(path.Join(uriAdmin, "/login"), a.LoginPage)
		g.POST(path.Join(uriAdmin, "/login"), a.LoginPage)
		g.GET(path.Join(uriAdmin, "/login/twofa"), a.TwofaPage)
		g.POST(path.Join(uriAdmin, "/login/twofa"), a.TwofaPage)
		g.GET(path.Join(uriAdmin, "/forgot"), a.ForgotPage)
		g.POST(path.Join(uriAdmin, "/forgot"), a.ForgotPage)
		g.GET(path.Join(uriAdmin, "/reset"), a.ResetPage)
		g.POST(path.Join(uriAdmin, "/reset"), a.ResetPage)

		if a.cfg.Security.OIDC.Enabled {
			g.POST("/auth/oidc", a.OIDCLogin)
			g.GET("/auth/oidc", a.OIDCFinish)
		}

		// Public APIs.
		g.GET("/api/public/lists", a.GetPublicLists)
		g.POST("/api/public/subscription", a.PublicSubscription)
		g.GET("/api/public/captcha/altcha", a.AltchaChallenge)
		if a.cfg.EnablePublicArchive {
			g.GET("/api/public/archive", a.GetCampaignArchives)
		}

		// /public/static/* file server is registered in initHTTPServer().
		// Public subscriber facing views.
		g.GET("/subscription/form", a.SubscriptionFormPage)
		g.POST("/subscription/form", a.SubscriptionForm)
		g.GET("/subscription/:campUUID/:subUUID", noIndex(a.hasUUID(a.hasSub(a.SubscriptionPage), "campUUID", "subUUID")))
		g.POST("/subscription/:campUUID/:subUUID", a.hasUUID(a.hasSub(a.SubscriptionPrefs), "campUUID", "subUUID"))
		g.GET("/subscription/optin/:subUUID", noIndex(a.hasUUID(a.hasSub(a.OptinPage), "subUUID")))
		g.POST("/subscription/optin/:subUUID", a.hasUUID(a.hasSub(a.OptinPage), "subUUID"))
		g.POST("/subscription/export/:subUUID", a.hasUUID(a.hasSub(a.SelfExportSubscriberData), "subUUID"))
		g.POST("/subscription/wipe/:subUUID", a.hasUUID(a.hasSub(a.WipeSubscriberData), "subUUID"))
		g.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(a.hasUUID(a.LinkRedirect, "linkUUID", "campUUID", "subUUID")))
		g.GET("/campaign/:campUUID/:subUUID", noIndex(a.hasUUID(a.ViewCampaignMessage, "campUUID", "subUUID")))
		g.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(a.hasUUID(a.RegisterCampaignView, "campUUID", "subUUID")))

		if a.cfg.EnablePublicArchive {
			g.GET("/archive", a.CampaignArchivesPage)
			g.GET("/archive.xml", a.GetCampaignArchivesFeed)
			g.GET("/archive/:id", a.CampaignArchivePage)
			g.GET("/archive/latest", a.CampaignArchivePageLatest)
		}

		g.GET("/public/custom.css", serveCustomAppearance("public.custom_css"))
		g.GET("/public/custom.js", serveCustomAppearance("public.custom_js"))

		// Public health API endpoint.
		g.GET("/health", a.HealthCheck)

		// 404 pages.
		g.RouteNotFound("/*", func(c echo.Context) error {
			return c.Render(http.StatusNotFound, tplMessage,
				makeMsgTpl("404 - "+a.i18n.T("public.notFoundTitle"), "", ""))
		})
		g.RouteNotFound("/api/*", func(c echo.Context) error {
			return echo.NewHTTPError(http.StatusNotFound, "404 unknown endpoint")
		})
		g.RouteNotFound("/admin/*", func(c echo.Context) error {
			return echo.NewHTTPError(http.StatusNotFound, "404 page not found")
		})
	}
}

// AdminPage is the root handler that renders the Javascript admin frontend.
func (a *App) AdminPage(c echo.Context) error {
	b, err := a.fs.Read(path.Join(uriAdmin, "/index.html"))
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	b = bytes.ReplaceAll(b, []byte("asset_version"), []byte(a.cfg.AssetVersion))

	return c.HTMLBlob(http.StatusOK, b)
}

// HealthCheck is a healthcheck endpoint that returns a 200 response.
func (a *App) HealthCheck(c echo.Context) error {
	return c.JSON(http.StatusOK, okResp{true})
}

// serveCustomAppearance serves the given custom CSS/JS appearance blob
// meant for customizing public and admin pages from the admin settings UI.
func serveCustomAppearance(name string) echo.HandlerFunc {
	return func(c echo.Context) error {
		var (
			app = c.Get("app").(*App)

			out []byte
			hdr string
		)

		switch name {
		case "admin.custom_css":
			out = app.cfg.Appearance.AdminCSS
			hdr = "text/css; charset=utf-8"

		case "admin.custom_js":
			out = app.cfg.Appearance.AdminJS
			hdr = "application/javascript; charset=utf-8"

		case "public.custom_css":
			out = app.cfg.Appearance.PublicCSS
			hdr = "text/css; charset=utf-8"

		case "public.custom_js":
			out = app.cfg.Appearance.PublicJS
			hdr = "application/javascript; charset=utf-8"
		}

		return c.Blob(http.StatusOK, hdr, out)
	}
}

// hasUUID middleware validates the UUID string format for a given set of params.
func (a *App) hasUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
	return func(c echo.Context) error {
		for _, p := range params {
			if !reUUID.MatchString(c.Param(p)) {
				return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "",
					a.i18n.T("globals.messages.invalidUUID")))
			}
		}
		return next(c)
	}
}

// hasID middleware validates the :id param in the URL and sets its int value in the context.
func hasID(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		id, _ := strconv.Atoi(c.Param("id"))
		if id < 1 {
			return echo.NewHTTPError(http.StatusBadRequest, "invalid ID")
		}

		c.Set("id", id)
		return next(c)
	}
}

// hasSub middleware checks if a subscriber exists given the UUID
// param in a request.
func (a *App) hasSub(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		subUUID := c.Param("subUUID")

		if _, err := a.core.GetSubscriber(0, subUUID, ""); err != nil {
			if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest {
				return c.Render(http.StatusNotFound, tplMessage,
					makeMsgTpl(a.i18n.T("public.notFoundTitle"), "", er.Message.(string)))
			}

			a.log.Printf("error checking subscriber existence: %v", err)
			return c.Render(http.StatusInternalServerError, tplMessage,
				makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.errorProcessingRequest")))
		}

		return next(c)
	}
}

// noIndex adds the HTTP header requesting robots to not crawl the page.
func noIndex(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		c.Response().Header().Set("X-Robots-Tag", "noindex")
		return next(c)
	}
}

// getID returns the :id param from the URL parsed and stored as an int by the hasID middleware.
func getID(c echo.Context) int {
	return c.Get("id").(int)
}


================================================
FILE: cmd/i18n.go
================================================
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"regexp"
	"sort"

	"github.com/knadh/listmonk/internal/i18n"
	"github.com/knadh/stuffbin"
	"github.com/labstack/echo/v4"
)

type i18nLang struct {
	Code string `json:"code"`
	Name string `json:"name"`
}

type i18nLangRaw struct {
	Code string `json:"_.code"`
	Name string `json:"_.name"`
}

var reLangCode = regexp.MustCompile(`[^a-zA-Z_0-9\\-]`)

// GetI18nLang returns the JSON language pack given the language code.
func (a *App) GetI18nLang(c echo.Context) error {
	lang := c.Param("lang")
	if len(lang) > 6 || reLangCode.MatchString(lang) {
		return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.")
	}

	i, ok, err := getI18nLang(lang, a.fs)
	if err != nil && !ok {
		return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
	}

	return c.JSON(http.StatusOK, okResp{json.RawMessage(i.JSON())})
}

// getI18nLangList returns the list of available i18n languages.
func getI18nLangList(fs stuffbin.FileSystem) ([]i18nLang, error) {
	list, err := fs.Glob("/i18n/*.json")
	if err != nil {
		return nil, err
	}

	// Read language JSON files from the fs.
	var out []i18nLang
	for _, l := range list {
		b, err := fs.Get(l)
		if err != nil {
			return out, fmt.Errorf("error reading lang file: %s: %v", l, err)
		}

		var r i18nLangRaw
		if err := json.Unmarshal(b.ReadBytes(), &r); err != nil {
			return out, fmt.Errorf("error parsing lang file: %s: %v", l, err)
		}

		out = append(out, i18nLang(r))
	}

	// Sort by language code.
	sort.SliceStable(out, func(i, j int) bool {
		return out[i].Code < out[j].Code
	})

	return out, nil
}

// The bool indicates whether the specified language could be loaded. If it couldn't
// be, the app shouldn't halt but throw a warning.
func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, bool, error) {
	const def = "en"

	b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def))
	if err != nil {
		return nil, false, fmt.Errorf("error reading default i18n language file: %s: %v", def, err)
	}

	// Initialize with the default language.
	i, err := i18n.New(b)
	if err != nil {
		return nil, false, fmt.Errorf("error unmarshalling i18n language: %s: %v", lang, err)
	}

	// Load the selected language on top of it.
	b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang))
	if err != nil {
		return i, true, fmt.Errorf("error reading i18n language file: %s: %v", lang, err)
	}
	if err := i.Load(b); err != nil {
		return i, true, fmt.Errorf("error loading i18n language file: %s: %v", lang, err)
	}

	return i, true, nil
}


================================================
FILE: cmd/import.go
================================================
package main

import (
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strings"

	"github.com/knadh/listmonk/internal/subimporter"
	"github.com/knadh/listmonk/models"
	"github.com/labstack/echo/v4"
)

// ImportSubscribers handles the uploading and bulk importing of
// a ZIP file of one or more CSV files.
func (a *App) ImportSubscribers(c echo.Context) error {
	// Is an import already running?
	if a.importer.GetStats().Status == subimporter.StatusImporting {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("import.alreadyRunning"))
	}

	// Unmarshal the JSON params.
	var opt subimporter.SessionOpt
	if err := json.Unmarshal([]byte(c.FormValue("params")), &opt); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("import.invalidParams", "error", err.Error()))
	}

	// Validate mode.
	if opt.Mode != subimporter.ModeSubscribe && opt.Mode != subimporter.ModeBlocklist {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("import.invalidMode"))
	}

	// If no status is specified, pick a default one.
	if opt.SubStatus == "" {
		switch opt.Mode {
		case subimporter.ModeSubscribe:
			opt.SubStatus = models.SubscriptionStatusUnconfirmed
		case subimporter.ModeBlocklist:
			opt.SubStatus = models.SubscriptionStatusUnsubscribed
		}
	}

	if opt.SubStatus != models.SubscriptionStatusUnconfirmed &&
		opt.SubStatus != models.SubscriptionStatusConfirmed &&
		opt.SubStatus != models.SubscriptionStatusUnsubscribed {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("import.invalidSubStatus"))
	}

	if len(opt.Delim) != 1 {
		return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("import.invalidDelim"))
	}

	// Open the HTTP file.
	file, err := c.FormFile("file")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest,
			a.i18n.Ts("import.invalidFile", "error", err.Error()))
	}

	src, err := file.Open()
	if err != nil {
		return err
	}
	defer src.Close()

	// Copy it to a temp location.
	out, err := os.CreateTemp("", "listmonk")
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError,
			a.i18n.Ts("import.errorCopyingFile", "error", err.Error()))
	}
	defer out.Close()

	if _, err = io.Copy(out, src); err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError,
			a.i18n.Ts("import.errorCopyingFile", "error", err.Error()))
	}

	// Start the importer session.
	opt.Filename = file.Filename
	sess, err := a.importer.NewSession(opt)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError,
			a.i18n.Ts("import.errorStarting", "error", err.Error()))
	}
	go sess.Start()

	if strings.HasSuffix(strings.ToLower(file.Filename), ".csv") {
		go sess.LoadCSV(out.Name(), rune(opt.Delim[0]))
	} else {
		// Only 1 CSV from the ZIP is considered. If multiple files have
		// to be processed, counting the net number of lines (to track progress),
		// keeping the global import state (failed / successful) etc. across
		// multiple files becomes complex. Instead, it's just easier for the
		// end user to concat multiple CSVs (if there are multiple in the first)
		// place and upload as one in the first place.
		dir, files, err := sess.ExtractZIP(out.Name(), 1)
		if err != nil {
			return echo.NewHTTPError(http.StatusInternalServerError,
				a.i18n.Ts("import.errorProcessingZIP", "error", err.Error()))
		}

		go sess.LoadCSV(dir+"/"+files[0], rune(opt.Delim[0]))
	}

	return c.JSON(http.StatusOK, okResp{a.importer.GetStats()})
}

// GetImportSubscribers returns import statistics.
func (a *App) GetImportSubscribers(c echo.Context) error {
	s := a.importer.GetStats()
	return c.JSON(http.StatusOK, okResp{s})
}

// GetImportSubscriberStats returns import statistics.
func (a *App) GetImportSubscriberStats(c echo.Context) error {
	return c.JSON(http.StatusOK, okResp{string(a.importer.GetLogs())})
}

// StopImportSubscribers sends a stop signal to the importer.
// If there's an ongoing import, it'll be stopped, and if an import
// is finished, it's state is cleared.
func (a *App) StopImportSubscribers(c echo.Context) error {
	a.importer.Stop()
	return c.JSON(http.StatusOK, okResp{a.importer.GetStats()})
}


================================================
FILE: cmd/init.go
================================================
package main

import (
	"bytes"
	"crypto/md5"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"log"
	"log/slog"
	"maps"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"strings"
	"syscall"
	"time"

	"github.com/Masterminds/sprig/v3"
	"github.com/gdgvda/cron"
	"github.com/jmoiron/sqlx"
	"github.com/jmoiron/sqlx/types"
	"github.com/knadh/goyesql/v2"
	goyesqlx "github.com/knadh/goyesql/v2/sqlx"
	koanfmaps "github.com/knadh/koanf/maps"
	"github.com/knadh/koanf/parsers/toml"
	"github.com/knadh/koanf/providers/confmap"
	"github.com/knadh/koanf/providers/file"
	"github.com/knadh/koanf/providers/posflag"
	"github.com/knadh/koanf/v2"
	"github.com/knadh/listmonk/internal/auth"
	"github.com/knadh/listmonk/internal/bounce"
	"github.com/knadh/listmonk/internal/bounce/mailbox"
	"github.com/knadh/listmonk/internal/captcha"
	"github.com/knadh/listmonk/internal/core"
	"github.com/knadh/listmonk/internal/i18n"
	"github.com/knadh/listmonk/internal/manager"
	"github.com/knadh/listmonk/internal/media"
	"github.com/knadh/listmonk/internal/media/providers/filesystem"
	"github.com/knadh/listmonk/internal/media/providers/s3"
	"github.com/knadh/listmonk/internal/messenger/email"
	"github.com/knadh/listmonk/internal/messenger/postback"
	"github.com/knadh/listmonk/internal/notifs"
	"github.com/knadh/listmonk/internal/subimporter"
	"github.com/knadh/listmonk/models"
	"github.com/knadh/stuffbin"
	"github.com/labstack/echo/v4"
	"github.com/lib/pq"
	flag "github.com/spf13/pflag"
	"gopkg.in/volatiletech/null.v6"
)

const (
	// Path to the SQL queries directory in the embedded FS.
	queryFilePath = "/queries"

	emailMsgr = "email"
)

// UrlConfig contains various URL constants used in the app.
type UrlConfig struct {
	RootURL      string `koanf:"root_url"`
	LogoURL      string `koanf:"logo_url"`
	FaviconURL   string `koanf:"favicon_url"`
	LoginURL     string `koanf:"login_url"`
	UnsubURL     string
	LinkTrackURL string
	ViewTrackURL string
	OptinURL     string
	MessageURL   string
	ArchiveURL   string
}

// Config contains static, constant config values required by arbitrary handlers and functions.
type Config struct {
	SiteName                      string   `koanf:"site_name"`
	FromEmail                     string   `koanf:"from_email"`
	NotifyEmails                  []string `koanf:"notify_emails"`
	EnablePublicSubPage           bool     `koanf:"enable_public_subscription_page"`
	EnablePublicArchive           bool     `koanf:"enable_public_archive"`
	EnablePublicArchiveRSSContent bool     `koanf:"enable_public_archive_rss_content"`
	Lang                          string   `koanf:"lang"`
	DBBatchSize                   int      `koanf:"batch_size"`
	Privacy                       struct {
		IndividualTracking bool            `koanf:"individual_tracking"`
		DisableTracking    bool            `koanf:"disable_tracking"`
		AllowPreferences   bool            `koanf:"allow_preferences"`
		AllowBlocklist     bool            `koanf:"allow_blocklist"`
		AllowExport        bool            `koanf:"allow_export"`
		AllowWipe          bool            `koanf:"allow_wipe"`
		RecordOptinIP      bool            `koanf:"record_optin_ip"`
		UnsubHeader        bool            `koanf:"unsubscribe_header"`
		Exportable         map[string]bool `koanf:"-"`
		DomainBlocklist    []string        `koanf:"-"`
		DomainAllowlist    []string        `koanf:"-"`
	} `koanf:"privacy"`
	Security struct {
		OIDC struct {
			Enabled           bool   `koanf:"enabled"`
			ProviderURL       string `koanf:"provider_url"`
			ProviderName      string `koanf:"provider_name"`
			ClientID          string `koanf:"client_id"`
			ClientSecret      string `koanf:"client_secret"`
			AutoCreateUsers   bool   `koanf:"auto_create_users"`
			DefaultUserRoleID int    `koanf:"default_user_role_id"`
			DefaultListRoleID int    `koanf:"default_list_role_id"`
		} `koanf:"oidc"`

		Captcha struct {
			Altcha struct {
				Enabled    bool `koanf:"enabled"`
				Complexity int  `koanf:"complexity"`
			} `koanf:"altcha"`
			HCaptcha struct {
				Enabled bool   `koanf:"enabled"`
				Key     string `koanf:"key"`
				Secret  string `koanf:"secret"`
			} `koanf:"hcaptcha"`
		} `koanf:"captcha"`

		CorsOrigins []string `koanf:"cors_origins"`
	} `koanf:"security"`

	Appearance struct {
		AdminCSS  []byte `koanf:"admin.custom_css"`
		AdminJS   []byte `koanf:"admin.custom_js"`
		PublicCSS []byte `koanf:"public.custom_css"`
		PublicJS  []byte `koanf:"public.custom_js"`
	}

	HasLegacyUser bool
	AssetVersion  string

	MediaUpload struct {
		Provider   string
		Extensions []string
	}

	BounceWebhooksEnabled     bool
	BounceSESEnabled          bool
	BounceSendgridEnabled     bool
	BouncePostmarkEnabled     bool
	BounceForwardemailEnabled bool

	PermissionsRaw json.RawMessage
	Permissions    map[string]struct{}
}

// initFlags initializes the commandline flags into the Koanf instance.
func initFlags(ko *koanf.Koanf) {
	f := flag.NewFlagSet("config", flag.ContinueOnError)
	f.Usage = func() {
		// Register --help handler.
		fmt.Println(f.FlagUsages())
		os.Exit(0)
	}

	// Register the commandline flags.
	f.StringSlice("config", []string{"config.toml"},
		"path to one or more config files (will be merged in order)")
	f.Bool("install", false, "setup database (first time)")
	f.Bool("idempotent", false, "make --install run only if the database isn't already setup")
	f.Bool("upgrade", false, "upgrade database to the current version")
	f.Bool("version", false, "show current version of the build")
	f.Bool("new-config", false, "generate sample config file (at path given in --config)")
	f.String("static-dir", "", "(optional) path to directory with static files")
	f.String("i18n-dir", "", "(optional) path to directory with i18n language files")
	f.Bool("yes", false, "assume 'yes' to prompts during --install/upgrade")
	f.Bool("passive", false, "run in passive mode where campaigns are not processed")
	if err := f.Parse(os.Args[1:]); err != nil {
		lo.Fatalf("error loading flags: %v", err)
	}

	if err := ko.Load(posflag.Provider(f, ".", ko), nil); err != nil {
		lo.Fatalf("error loading config: %v", err)
	}
}

// initConfigFiles loads the given config files into the koanf instance.
func initConfigFiles(files []string, ko *koanf.Koanf) {
	for _, f := range files {
		lo.Printf("reading config: %s", f)
		if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
			if os.IsNotExist(err) {
				lo.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.")
			}
			lo.Fatalf("error loading config from file: %v.", err)
		}
	}
}

// initFileSystem initializes the stuffbin FileSystem to provide
// access to bundled static assets to the app.
func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem {
	var (
		// stuffbin real_path:virtual_alias paths to map local assets on disk
		// when there an embedded filestystem is not found.

		// These paths are joined with appDir.
		appFiles = []string{
			"./config.toml.sample:config.toml.sample",
			"./queries:queries",
			"./schema.sql:schema.sql",
			"./permissions.json:permissions.json",
		}

		frontendFiles = []string{
			// Admin frontend's static assets accessible at /admin/* during runtime.
			// These paths are sourced from frontendDir.
			"./:/admin",
		}

		staticFiles = []string{
			// These paths are joined with staticDir.
			"./email-templates:static/email-templates",
			"./public:/public",
		}

		i18nFiles = []string{
			// These paths are joined with i18nDir.
			"./:/i18n",
		}
	)

	// Get the executable's execPath.
	execPath, err := os.Executable()
	if err != nil {
		lo.Fatalf("error getting executable path: %v", err)
	}

	// Load embedded files in the executable.
	hasEmbed := true
	fs, err := stuffbin.UnStuff(execPath)
	if err != nil {
		hasEmbed = false

		// Running in local mode. Load local assets into
		// the in-memory stuffbin.FileSystem.
		lo.Printf("unable to initialize embedded filesystem (%v). Using local filesystem", err)

		fs, err = stuffbin.NewLocalFS("/")
		if err != nil {
			lo.Fatalf("failed to initialize local file for assets: %v", err)
		}
	}

	// If the embed failed, load app and frontend files from the compile-time paths.
	files := []string{}
	if !hasEmbed {
		files = append(files, joinFSPaths(appDir, appFiles)...)
		files = append(files, joinFSPaths(frontendDir, frontendFiles)...)
	}

	// Irrespective of the embeds, if there are user specified static or i18n paths,
	// load files from there and override default files (embedded or picked up from CWD).
	if !hasEmbed || i18nDir != "" {
		if i18nDir == "" {
			// Default dir in cwd.
			i18nDir = "i18n"
		}
		lo.Printf("loading i18n files from: %v", i18nDir)
		files = append(files, joinFSPaths(i18nDir, i18nFiles)...)
	}

	if !hasEmbed || staticDir != "" {
		if staticDir == "" {
			// Default dir in cwd.
			staticDir = "static"
		} else {
			// There is a custom static directory. Any paths that aren't in it, exclude.
			sf := []string{}
			for _, def := range staticFiles {
				s := strings.Split(def, ":")[0]
				if _, err := os.Stat(path.Join(staticDir, s)); err == nil {
					sf = append(sf, def)
				}
			}
			staticFiles = sf
		}

		lo.Printf("loading static files from: %v", staticDir)
		files = append(files, joinFSPaths(staticDir, staticFiles)...)
	}

	// No additional files to load.
	if len(files) == 0 {
		return fs
	}

	// Load files from disk and overlay into the FS.
	fStatic, err := stuffbin.NewLocalFS("/", files...)
	if err != nil {
		lo.Fatalf("failed reading static files from disk: '%s': %v", staticDir, err)
	}

	if err := fs.Merge(fStatic); err != nil {
		lo.Fatalf("error merging static files: '%s': %v", staticDir, err)
	}

	return fs
}

// initDB initializes the main DB connection pool and parse and loads the app's
// SQL queries into a prepared query map.
func initDB() *sqlx.DB {
	var c struct {
		Host        string        `koanf:"host"`
		Port        int           `koanf:"port"`
		User        string        `koanf:"user"`
		Password    string        `koanf:"password"`
		DBName      string        `koanf:"database"`
		SSLMode     string        `koanf:"ssl_mode"`
		Params      string        `koanf:"params"`
		MaxOpen     int           `koanf:"max_open"`
		MaxIdle     int           `koanf:"max_idle"`
		MaxLifetime time.Duration `koanf:"max_lifetime"`
	}
	if err := ko.Unmarshal("db", &c); err != nil {
		lo.Fatalf("error loading db config: %v", err)
	}

	lo.Printf("connecting to db: %s:%d/%s", c.Host, c.Port, c.DBName)
	db, err := sqlx.Connect("postgres",
		fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s %s", c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode, c.Params))
	if err != nil {
		lo.Fatalf("error connecting to DB: %v", err)
	}

	db.SetMaxOpenConns(c.MaxOpen)
	db.SetMaxIdleConns(c.MaxIdle)
	db.SetConnMaxLifetime(c.MaxLifetime)

	return db.Unsafe()
}

func readQueries(dir string, fs stuffbin.FileSystem) goyesql.Queries {
	out := goyesql.Queries{}

	// Glob all the .sql files in the queries directory.
	qPath := path.Join(dir, "/*.sql")
	files, err := fs.Glob(qPath)
	if err != nil {
		lo.Fatalf("error reading *.sql query files from %s: %v", qPath, err)
	}

	// Read and merge queries from all files into one map.
	for _, file := range files {
		// Read the SQL file.
		b, err := fs.Read(file)
		if err != nil {
			lo.Fatalf("error reading SQL file %s: %v", file, err)
		}

		// Parse queries in it into a map.
		mp, err := goyesql.ParseBytes(b)
		if err != nil {
			lo.Fatalf("error parsing SQL queries: %v", err)
		}

		// Merge into the main query map.
		maps.Copy(out, mp)
	}

	return out
}

// prepareQueries queries prepares a query map and returns a *Queries
func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *models.Queries {
	var (
		countQuery = "get-campaign-analytics-counts"
		linkSel    = "*"
	)
	if ko.Bool("privacy.individual_tracking") {
		countQuery = "get-campaign-analytics-unique-counts"
		linkSel = "DISTINCT subscriber_id"
	}

	// These don't exist in the SQL file but are in the queries struct to be prepared.
	qMap["get-campaign-view-counts"] = &goyesql.Query{
		Query: fmt.Sprintf(qMap[countQuery].Query, "campaign_views"),
		Tags:  map[string]string{"name": "get-campaign-view-counts"},
	}
	qMap["get-campaign-click-counts"] = &goyesql.Query{
		Query: fmt.Sprintf(qMap[countQuery].Query, "link_clicks"),
		Tags:  map[string]string{"name": "get-campaign-click-counts"},
	}
	qMap["get-campaign-link-counts"].Query = fmt.Sprintf(qMap["get-campaign-link-counts"].Query, linkSel)

	// Scan and prepare all queries.
	var q models.Queries
	if err := goyesqlx.ScanToStruct(&q, qMap, db); err != nil {
		lo.Fatalf("error preparing SQL queries: %v", err)
	}

	return &q
}

// initSettings loads settings from the DB into the given Koanf map.
func initSettings(query string, db *sqlx.DB, ko *koanf.Koanf) {
	var s types.JSONText
	if err := db.Get(&s, query); err != nil {
		msg := err.Error()
		if err, ok := err.(*pq.Error); ok {
			if err.Detail != "" {
				msg = fmt.Sprintf("%s. %s", err, err.Detail)
			}
		}

		lo.Fatalf("error reading settings from DB: %s", msg)
	}

	// Setting keys are dot separated, eg: app.favicon_url. Unflatten them into
	// nested maps {app: {favicon_url}}.
	var out map[string]any
	if err := json.Unmarshal(s, &out); err != nil {
		lo.Fatalf("error unmarshalling settings from DB: %v", err)
	}
	if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
		lo.Fatalf("error parsing settings from DB: %v", err)
	}
}

func initUrlConfig(ko *koanf.Koanf) *UrlConfig {
	root := strings.TrimSuffix(ko.String("app.root_url"), "/")

	return &UrlConfig{
		RootURL:    root,
		LogoURL:    ko.String("app.logo_url"),
		FaviconURL: ko.String("app.favicon_url"),
		LoginURL:   path.Join(uriAdmin, "/login"),

		// Static URLS.
		// url.com/subscription/{campaign_uuid}/{subscriber_uuid}
		UnsubURL: fmt.Sprintf("%s/subscription/%%s/%%s", root),

		// url.com/subscription/optin/{subscriber_uuid}
		OptinURL: fmt.Sprintf("%s/subscription/optin/%%s?%%s", root),

		// url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid}
		LinkTrackURL: fmt.Sprintf("%s/link/%%s/%%s/%%s", root),

		// url.com/link/{campaign_uuid}/{subscriber_uuid}
		MessageURL: fmt.Sprintf("%s/campaign/%%s/%%s", root),

		// url.com/archive
		ArchiveURL: root + "/archive",

		// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
		ViewTrackURL: fmt.Sprintf("%s/campaign/%%s/%%s/px.png", root),
	}
}

// initConstConfig initializes the app's global constants from the given koanf instance.
func initConstConfig(ko *koanf.Koanf) *Config {
	// Read constants.
	var c Config
	if err := ko.Unmarshal("app", &c); err != nil {
		lo.Fatalf("error loading app config: %v", err)
	}
	if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
		lo.Fatalf("error loading app.privacy config: %v", err)
	}
	if err := ko.Unmarshal("security", &c.Security); err != nil {
		lo.Fatalf("error loading app.security config: %v", err)
	}

	if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil {
		lo.Fatalf("error loading app.appearance config: %v", err)
	}

	c.Lang = ko.String("app.lang")
	c.Privacy.Exportable = koanfmaps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
	c.MediaUpload.Provider = ko.String("upload.provider")
	c.MediaUpload.Extensions = ko.Strings("upload.extensions")
	c.Privacy.DomainBlocklist = ko.Strings("privacy.domain_blocklist")
	c.Privacy.DomainAllowlist = ko.Strings("privacy.domain_allowlist")

	c.BounceWebhooksEnabled = ko.Bool("bounce.webhooks_enabled")
	c.BounceSESEnabled = ko.Bool("bounce.ses_enabled")
	c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled")
	c.BouncePostmarkEnabled = ko.Bool("bounce.postmark.enabled")
	c.BounceForwardemailEnabled = ko.Bool("bounce.forwardemail.enabled")
	c.HasLegacyUser = ko.Exists("app.admin_username") || ko.Exists("app.admin_password")

	b := md5.Sum([]byte(time.Now().String()))
	c.AssetVersion = fmt.Sprintf("%x", b)[0:10]

	pm, err := fs.Read("/permissions.json")
	if err != nil {
		lo.Fatalf("error reading permissions file: %v", err)
	}
	c.PermissionsRaw = pm

	// Make a lookup map of permissions.
	permGroups := []struct {
		Group       string   `json:"group"`
		Permissions []string `json:"permissions"`
	}{}
	if err := json.Unmarshal(pm, &permGroups); err != nil {
		lo.Fatalf("error loading permissions file: %v", err)
	}

	c.Permissions = map[string]struct{}{}
	for _, group := range permGroups {
		for _, g := range group.Permissions {
			c.Permissions[g] = struct{}{}
		}
	}

	return &c
}

// initI18n initializes a new i18n instance with the selected language map
// loaded from the filesystem. English is a loaded first as the default map
// and then the selected language is loaded on top of it so that if there are
// missing translations in it, the default English translations show up.
func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
	i, ok, err := getI18nLang(lang, fs)
	if err != nil {
		if ok {
			lo.Println(err)
		} else {
			lo.Fatal(err)
		}
	}
	return i
}

// initCore initializes the CRUD DB core .
func initCore(fnNotify func(sub models.Subscriber, listIDs []int) (int, error), queries *models.Queries, db *sqlx.DB, i *i18n.I18n, ko *koanf.Koanf) *core.Core {
	opt := &core.Opt{
		Constants: core.Constants{
			SendOptinConfirmation: ko.Bool("app.send_optin_confirmation"),
			CacheSlowQueries:      ko.Bool("app.cache_slow_queries"),
		},
		Queries: queries,
		DB:      db,
		I18n:    i,
		Log:     lo,
	}

	// Load bounce config.
	if err := ko.Unmarshal("bounce.actions", &opt.Constants.BounceActions); err != nil {
		lo.Fatalf("error unmarshalling bounce config: %v", err)
	}

	// Initialize the CRUD core.
	return core.New(opt, &core.Hooks{
		SendOptinConfirmation: fnNotify,
	})
}

// initCampaignManager initializes the campaign manager.
func initCampaignManager(msgrs []manager.Messenger, q *models.Queries, u *UrlConfig, co *core.Core, md media.Store, i *i18n.I18n, ko *koanf.Koanf) *manager.Manager {
	if ko.Bool("passive") {
		lo.Println("running in passive mode. won't process campaigns.")
	}

	mgr := manager.New(manager.Config{
		BatchSize:             ko.Int("app.batch_size"),
		Concurrency:           ko.Int("app.concurrency"),
		MessageRate:           ko.Int("app.message_rate"),
		MaxSendErrors:         ko.Int("app.max_send_errors"),
		FromEmail:             ko.String("app.from_email"),
		IndividualTracking:    ko.Bool("privacy.individual_tracking"),
		DisableTracking:       ko.Bool("privacy.disable_tracking"),
		UnsubURL:              u.UnsubURL,
		OptinURL:              u.OptinURL,
		LinkTrackURL:          u.LinkTrackURL,
		ViewTrackURL:          u.ViewTrackURL,
		MessageURL:            u.MessageURL,
		ArchiveURL:            u.ArchiveURL,
		RootURL:               u.RootURL,
		UnsubHeader:           ko.Bool("privacy.unsubscribe_header"),
		SlidingWindow:         ko.Bool("app.message_sliding_window"),
		SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"),
		SlidingWindowRate:     ko.Int("app.message_sliding_window_rate"),
		ScanInterval:          time.Second * 5,
		ScanCampaigns:         !ko.Bool("passive"),
	}, newManagerStore(q, co, md), i, lo)

	// Attach all messengers to the campaign manager.
	for _, m := range msgrs {
		mgr.AddMessenger(m)
	}

	return mgr
}

// initTxTemplates initializes and compiles the transactional templates and caches them in-memory.
func initTxTemplates(m *manager.Manager, co *core.Core) {
	tpls, err := co.GetTemplates(models.TemplateTypeTx, false)
	if err != nil {
		lo.Fatalf("error loading transactional templates: %v", err)
	}

	for _, t := range tpls {
		tpl := t
		if err := tpl.Compile(m.GenericTemplateFuncs()); err != nil {
			lo.Printf("error compiling transactional template %d: %v", tpl.ID, err)
			continue
		}
		m.CacheTpl(tpl.ID, &tpl)
	}
}

// initImporter initializes the bulk subscriber importer.
func initImporter(q *models.Queries, db *sqlx.DB, core *core.Core, i *i18n.I18n, ko *koanf.Koanf) *subimporter.Importer {
	return subimporter.New(
		subimporter.Options{
			DomainBlocklist:    ko.Strings("privacy.domain_blocklist"),
			DomainAllowlist:    ko.Strings("privacy.domain_allowlist"),
			UpsertStmt:         q.UpsertSubscriber.Stmt,
			BlocklistStmt:      q.UpsertBlocklistSubscriber.Stmt,
			UpdateListDateStmt: q.UpdateListsDate.Stmt,

			// Hook for triggering admin notifications and refreshing stats materialized
			// views after a successful import.
			PostCB: func(subject string, data any) error {
				// Refresh cached subscriber counts and stats.
				core.RefreshMatViews(true)

				// Send admin notification.
				notifs.NotifySystem(subject, notifs.TplImport, data, nil)
				return nil
			},
		}, db.DB, i)
}

// initSMTPMessenger initializes the combined and individual SMTP messengers.
func initSMTPMessengers() []manager.Messenger {
	var (
		servers = []email.Server{}
		out     = []manager.Messenger{}
	)

	// Load the config for multiple SMTP servers.
	for _, item := range ko.Slices("smtp") {
		if !item.Bool("enabled") {
			continue
		}

		// Read the SMTP config.
		var s email.Server
		if err := item.UnmarshalWithConf("", &s, koanf.UnmarshalConf{Tag: "json"}); err != nil {
			lo.Fatalf("error reading SMTP config: %v", err)
		}

		servers = append(servers, s)
		lo.Printf("initialized email (SMTP) messenger: %s@%s", item.String("username"), item.String("host"))

		// If the server has a name, initialize it as a standalone e-mail messenger
		// allowing campaigns to select individual SMTPs. In the UI and config, it'll appear as `email / $name`.
		if s.Name != "" {
			msgr, err := email.New(s.Name, s)
			if err != nil {
				lo.Fatalf("error initializing e-mail messenger: %v", err)
			}
			out = append(out, msgr)
		}
	}

	// Initialize the 'email' messenger with all SMTP servers.
	msgr, err := email.New(email.MessengerName, servers...)
	if err != nil {
		lo.Fatalf("error initializing e-mail messenger: %v", err)
	}

	// If it's just one server, return the default "email" messenger.
	if len(servers) == 1 {
		return []manager.Messenger{msgr}
	}

	// If there are multiple servers, prepend the group "email" to be the first one.
	out = append([]manager.Messenger{msgr}, out...)

	return out
}

// initPostbackMessengers initializes and returns all the enabled
// HTTP postback messenger backends.
func initPostbackMessengers(ko *koanf.Koanf) []manager.Messenger {
	items := ko.Slices("messengers")
	if len(items) == 0 {
		return nil
	}

	var out []manager.Messenger
	for _, item := range items {
		if !item.Bool("enabled") {
			continue
		}

		// Read the Postback server config.
		var (
			name = item.String("name")
			o    postback.Options
		)
		if err := item.UnmarshalWithConf("", &o, koanf.UnmarshalConf{Tag: "json"}); err != nil {
			lo.Fatalf("error reading Postback config: %v", err)
		}

		// Initialize the Messenger.
		p, err := postback.New(o)
		if err != nil {
			lo.Fatalf("error initializing Postback messenger %s: %v", name, err)
		}
		out = append(out, p)

		lo.Printf("loaded Postback messenger: %s", name)
	}

	return out
}

// initMediaStore initializes Upload manager with a custom backend.
func initMediaStore(ko *koanf.Koanf) media.Store {
	switch provider := ko.String("upload.provider"); provider {
	case "s3":
		var o s3.Opt
		ko.Unmarshal("upload.s3", &o)
		o.RootURL = ko.String("app.root_url")

		up, err := s3.NewS3Store(o)
		if err != nil {
			lo.Fatalf("error initializing s3 upload provider %s", err)
		}
		lo.Println("media upload provider: s3")
		return up

	case "filesystem":
		var o filesystem.Opts

		ko.Unmarshal("upload.filesystem", &o)
		o.RootURL = ko.String("app.root_url")
		o.UploadPath = filepath.Clean(o.UploadPath)
		o.UploadURI = filepath.Clean(o.UploadURI)
		up, err := filesystem.New(o)
		if err != nil {
			lo.Fatalf("error initializing filesystem upload provider %s", err)
		}
		lo.Println("media upload provider: filesystem")
		return up

	default:
		lo.Fatalf("unknown provider. select filesystem or s3")
	}
	return nil
}

// initNotifs initializes the notifier with the system e-mail templates.
func initNotifs(fs stuffbin.FileSystem, i *i18n.I18n, em *email.Emailer, u *UrlConfig, ko *koanf.Koanf) {
	tpls, err := stuffbin.ParseTemplatesGlob(initTplFuncs(i, u), fs, "/static/email-templates/*.html")
	if err != nil {
		lo.Fatalf("error parsing e-mail notif templates: %v", err)
	}

	// Read the notification templates.
	html, err := fs.Read("/static/email-templates/base.html")
	if err != nil {
		lo.Fatalf("error reading static/email-templates/base.html: %v", err)
	}

	// Determine whether the notification templates are HTML or plaintext.
	// Copy the first few (arbitrary) bytes of the template and check if has the <!doctype html> tag.
	ln := min(len(html), 256)
	h := make([]byte, ln)
	copy(h, html[0:ln])

	contentType := models.CampaignContentTypeHTML
	if !bytes.Contains(bytes.ToLower(h), []byte("<!doctype html")) {
		contentType = models.CampaignContentTypePlain
		lo.Println("system e-mail templates are plaintext")
	}

	notifs.Initialize(notifs.Opt{
		FromEmail:    ko.String("app.from_email"),
		SystemEmails: ko.Strings("app.notify_emails"),
		ContentType:  contentType,
	}, tpls, em, lo)
}

// initBounceManager initializes the bounce manager that scans mailboxes and listens to webhooks
// for incoming bounce events.
func initBounceManager(cb func(models.Bounce) error, stmt *sqlx.Stmt, lo *log.Logger, ko *koanf.Koanf) *bounce.Manager {
	opt := bounce.Opt{
		WebhooksEnabled: ko.Bool("bounce.webhooks_enabled"),
		SESEnabled:      ko.Bool("bounce.ses_enabled"),
		SendgridEnabled: ko.Bool("bounce.sendgrid_enabled"),
		SendgridKey:     ko.String("bounce.sendgrid_key"),
		Postmark: struct {
			Enabled  bool
			Username string
			Password string
		}{
			ko.Bool("bounce.postmark.enabled"),
			ko.String("bounce.postmark.username"),
			ko.String("bounce.postmark.password"),
		},
		ForwardEmail: struct {
			Enabled bool
			Key     string
		}{
			ko.Bool("bounce.forwardemail.enabled"),
			ko.String("bounce.forwardemail.key"),
		},
		RecordBounceCB: cb,
	}

	// For now, only one mailbox is supported.
	for _, b := range ko.Slices("bounce.mailboxes") {
		if !b.Bool("enabled") {
			continue
		}

		var boxOpt mailbox.Opt
		if err := b.UnmarshalWithConf("", &boxOpt, koanf.UnmarshalConf{Tag: "json"}); err != nil {
			lo.Fatalf("error reading bounce mailbox config: %v", err)
		}

		opt.MailboxType = b.String("type")
		opt.MailboxEnabled = true
		opt.Mailbox = boxOpt
		break
	}

	// Initialize the bounce manager.
	b, err := bounce.New(opt, &bounce.Queries{RecordQuery: stmt}, lo)
	if err != nil {
		lo.Fatalf("error initializing bounce manager: %v", err)
	}

	return b
}

// initAbout initializes the app's /about API endpoint with the app and system info.
func initAbout(q *models.Queries, db *sqlx.DB) about {
	var (
		mem runtime.MemStats
	)

	// Memory / alloc stats.
	runtime.ReadMemStats(&mem)

	info := types.JSONText(`{}`)
	if err := db.QueryRow(q.GetDBInfo).Scan(&info); err != nil {
		lo.Printf("WARNING: error getting database version: %v", err)
	}

	hostname, err := os.Hostname()
	if err != nil {
		lo.Printf("WARNING: error getting hostname: %v", err)
	}

	return about{
		Version:   versionString,
		Build:     buildString,
		GoArch:    runtime.GOARCH,
		GoVersion: runtime.Version(),
		Database:  info,
		System: aboutSystem{
			NumCPU: runtime.NumCPU(),
		},
		Host: aboutHost{
			OS:       runtime.GOOS,
			Machine:  runtime.GOARCH,
			Hostname: hostname,
		},
	}

}

// initHTTPServer sets up and runs the app's main HTTP server and blocks forever.
func initHTTPServer(cfg *Config, urlCfg *UrlConfig, i *i18n.I18n, fs stuffbin.FileSystem, app *App) *echo.Echo {
	// Initialize the HTTP server.
	var srv = echo.New()
	srv.HideBanner = true

	// Register app (*App) to be injected into all HTTP handlers.
	srv.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			c.Set("app", app)
			return next(c)
		}
	})

	tpl, err := stuffbin.ParseTemplatesGlob(initTplFuncs(i, urlCfg), fs, "/public/templates/*.html")
	if err != nil {
		lo.Fatalf("error parsing public templates: %v", err)
	}
	srv.Renderer = &tplRenderer{
		templates:           tpl,
		SiteName:            cfg.SiteName,
		RootURL:             urlCfg.RootURL,
		LogoURL:             urlCfg.LogoURL,
		FaviconURL:          urlCfg.FaviconURL,
		AssetVersion:        cfg.AssetVersion,
		EnablePublicSubPage: cfg.EnablePublicSubPage,
		EnablePublicArchive: cfg.EnablePublicArchive,
		IndividualTracking:  cfg.Privacy.IndividualTracking,
	}

	// Initialize the static file server.
	fSrv := fs.FileServer()

	// Public (subscriber) facing static files.
	srv.GET("/public/static/*", echo.WrapHandler(fSrv))

	// Admin (frontend) facing static files.
	srv.GET("/admin/static/*", echo.WrapHandler(fSrv))

	// Public (subscriber) facing media upload files.
	var (
		uploadProvider = ko.String("upload.provider")
		uploadFsURI    = ko.String("upload.filesystem.upload_uri")
		publicURL      = ko.String("upload.s3.public_url")
	)
	switch {
	case uploadProvider == "filesystem" && uploadFsURI != "":
		srv.Static(uploadFsURI, ko.String("upload.filesystem.upload_path"))
	case uploadProvider == "s3" && strings.HasPrefix(publicURL, "/"):
		srv.GET(path.Join(publicURL, "/:filepath"), app.ServeS3Media)
	}

	// Register all HTTP handlers.
	initHTTPHandlers(srv, app)

	// Start the server.
	go func() {
		if err := srv.Start(ko.String("app.address")); err != nil {
			if errors.Is(err, http.ErrServerClosed) {
				lo.Println("HTTP server shut down")
			} else {
				lo.Fatalf("error starting HTTP server: %v
Download .txt
gitextract_pz3pib5l/

├── .devcontainer/
│   └── devcontainer.json
├── .dockerignore
├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── confirmed-bug.md
│   │   ├── feature-or-change-request.md
│   │   ├── general-question.md
│   │   └── possible-bug--needs-investigation-.md
│   └── workflows/
│       ├── build-sanity.yml
│       ├── github-pages.yml
│       ├── hodor-review.yml
│       ├── issues.yml
│       ├── nightly.yml
│       └── release.yml
├── .gitignore
├── .go-version
├── .goreleaser-nightly.yml
├── .goreleaser.yml
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── VERSION
├── cmd/
│   ├── admin.go
│   ├── archive.go
│   ├── auth.go
│   ├── bounce.go
│   ├── campaigns.go
│   ├── events.go
│   ├── handlers.go
│   ├── i18n.go
│   ├── import.go
│   ├── init.go
│   ├── install.go
│   ├── lists.go
│   ├── main.go
│   ├── maintenance.go
│   ├── manager_store.go
│   ├── media.go
│   ├── public.go
│   ├── roles.go
│   ├── settings.go
│   ├── subscribers.go
│   ├── templates.go
│   ├── tx.go
│   ├── updates.go
│   ├── upgrade.go
│   ├── users.go
│   └── utils.go
├── config.toml.sample
├── dev/
│   ├── .gitignore
│   ├── README.md
│   ├── app.Dockerfile
│   ├── config.toml
│   └── docker-compose.yml
├── docker-compose.yml
├── docker-entrypoint.sh
├── docs/
│   ├── README.md
│   ├── docs/
│   │   ├── content/
│   │   │   ├── apis/
│   │   │   │   ├── apis.md
│   │   │   │   ├── bounces.md
│   │   │   │   ├── campaigns.md
│   │   │   │   ├── import.md
│   │   │   │   ├── lists.md
│   │   │   │   ├── media.md
│   │   │   │   ├── sdks.md
│   │   │   │   ├── subscribers.md
│   │   │   │   ├── templates.md
│   │   │   │   └── transactional.md
│   │   │   ├── archives.md
│   │   │   ├── bounces.md
│   │   │   ├── concepts.md
│   │   │   ├── configuration.md
│   │   │   ├── developer-setup.md
│   │   │   ├── external-integration.md
│   │   │   ├── i18n.md
│   │   │   ├── index.md
│   │   │   ├── installation.md
│   │   │   ├── maintenance/
│   │   │   │   └── performance.md
│   │   │   ├── messengers.md
│   │   │   ├── oidc.md
│   │   │   ├── querying-and-segmentation.md
│   │   │   ├── roles-and-permissions.md
│   │   │   ├── security-reports.md
│   │   │   ├── static/
│   │   │   │   └── style.css
│   │   │   ├── templating.md
│   │   │   └── upgrade.md
│   │   ├── mkdocs.yml
│   │   └── requirements.txt
│   ├── i18n/
│   │   ├── index.html
│   │   ├── main.js
│   │   └── style.css
│   ├── site/
│   │   ├── content/
│   │   │   └── .gitignore
│   │   ├── data/
│   │   │   └── github.json
│   │   ├── layouts/
│   │   │   ├── index.html
│   │   │   ├── page/
│   │   │   │   └── single.html
│   │   │   ├── partials/
│   │   │   │   ├── footer.html
│   │   │   │   └── header.html
│   │   │   └── shortcodes/
│   │   │       ├── centered.html
│   │   │       ├── github.html
│   │   │       ├── half.html
│   │   │       └── section.html
│   │   └── static/
│   │       └── static/
│   │           ├── base.css
│   │           └── style.css
│   └── swagger/
│       └── collections.yaml
├── frontend/
│   ├── .browserslistrc
│   ├── .editorconfig
│   ├── .eslintrc.js
│   ├── .gitignore
│   ├── README.md
│   ├── babel.config.js
│   ├── cypress/
│   │   ├── e2e/
│   │   │   ├── archive.cy.js
│   │   │   ├── bounces.cy.js
│   │   │   ├── campaigns.cy.js
│   │   │   ├── dashboard.cy.js
│   │   │   ├── forms.cy.js
│   │   │   ├── import.cy.js
│   │   │   ├── lists.cy.js
│   │   │   ├── settings.cy.js
│   │   │   ├── subscribers.cy.js
│   │   │   ├── templates.cy.js
│   │   │   └── users.cy.js
│   │   ├── fixtures/
│   │   │   ├── subs-domain-blocklist.csv
│   │   │   └── subs.csv
│   │   ├── plugins/
│   │   │   └── index.js
│   │   └── support/
│   │       ├── commands.js
│   │       ├── e2e.js
│   │       └── reset.sh
│   ├── cypress.config.js
│   ├── email-builder/
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── App/
│   │   │   │   ├── InspectorDrawer/
│   │   │   │   │   ├── ConfigurationPanel/
│   │   │   │   │   │   ├── index.tsx
│   │   │   │   │   │   └── input-panels/
│   │   │   │   │   │       ├── AvatarSidebarPanel.tsx
│   │   │   │   │   │       ├── ButtonSidebarPanel.tsx
│   │   │   │   │   │       ├── ColumnsContainerSidebarPanel.tsx
│   │   │   │   │   │       ├── ContainerSidebarPanel.tsx
│   │   │   │   │   │       ├── DividerSidebarPanel.tsx
│   │   │   │   │   │       ├── EmailLayoutSidebarPanel.tsx
│   │   │   │   │   │       ├── HeadingSidebarPanel.tsx
│   │   │   │   │   │       ├── HtmlSidebarPanel.tsx
│   │   │   │   │   │       ├── ImageSidebarPanel.tsx
│   │   │   │   │   │       ├── SpacerSidebarPanel.tsx
│   │   │   │   │   │       ├── TextSidebarPanel.tsx
│   │   │   │   │   │       └── helpers/
│   │   │   │   │   │           ├── BaseSidebarPanel.tsx
│   │   │   │   │   │           ├── inputs/
│   │   │   │   │   │           │   ├── BooleanInput.tsx
│   │   │   │   │   │           │   ├── ColorInput/
│   │   │   │   │   │           │   │   ├── BaseColorInput.tsx
│   │   │   │   │   │           │   │   ├── Picker.tsx
│   │   │   │   │   │           │   │   ├── Swatch.tsx
│   │   │   │   │   │           │   │   └── index.tsx
│   │   │   │   │   │           │   ├── ColumnWidthsInput.tsx
│   │   │   │   │   │           │   ├── FontFamily.tsx
│   │   │   │   │   │           │   ├── FontSizeInput.tsx
│   │   │   │   │   │           │   ├── FontWeightInput.tsx
│   │   │   │   │   │           │   ├── PaddingInput.tsx
│   │   │   │   │   │           │   ├── RadioGroupInput.tsx
│   │   │   │   │   │           │   ├── SliderInput.tsx
│   │   │   │   │   │           │   ├── TextAlignInput.tsx
│   │   │   │   │   │           │   ├── TextDimensionInput.tsx
│   │   │   │   │   │           │   ├── TextInput.tsx
│   │   │   │   │   │           │   └── raw/
│   │   │   │   │   │           │       └── RawSliderInput.tsx
│   │   │   │   │   │           └── style-inputs/
│   │   │   │   │   │               ├── MultiStylePropertyPanel.tsx
│   │   │   │   │   │               └── SingleStylePropertyPanel.tsx
│   │   │   │   │   ├── StylesPanel.tsx
│   │   │   │   │   ├── ToggleInspectorPanelButton.tsx
│   │   │   │   │   └── index.tsx
│   │   │   │   ├── TemplatePanel/
│   │   │   │   │   ├── DownloadJson/
│   │   │   │   │   │   └── index.tsx
│   │   │   │   │   ├── HtmlPanel.tsx
│   │   │   │   │   ├── ImportJson/
│   │   │   │   │   │   ├── ImportJsonDialog.tsx
│   │   │   │   │   │   ├── index.tsx
│   │   │   │   │   │   └── validateJsonStringValue.ts
│   │   │   │   │   ├── JsonPanel.tsx
│   │   │   │   │   ├── MainTabsGroup.tsx
│   │   │   │   │   ├── ShareButton.tsx
│   │   │   │   │   ├── helper/
│   │   │   │   │   │   ├── HighlightedCodePanel.tsx
│   │   │   │   │   │   └── highlighters.tsx
│   │   │   │   │   └── index.tsx
│   │   │   │   └── index.tsx
│   │   │   ├── documents/
│   │   │   │   ├── blocks/
│   │   │   │   │   ├── ColumnsContainer/
│   │   │   │   │   │   ├── ColumnsContainerEditor.tsx
│   │   │   │   │   │   └── ColumnsContainerPropsSchema.ts
│   │   │   │   │   ├── Container/
│   │   │   │   │   │   ├── ContainerEditor.tsx
│   │   │   │   │   │   └── ContainerPropsSchema.tsx
│   │   │   │   │   ├── EmailLayout/
│   │   │   │   │   │   ├── EmailLayoutEditor.tsx
│   │   │   │   │   │   └── EmailLayoutPropsSchema.tsx
│   │   │   │   │   └── helpers/
│   │   │   │   │       ├── EditorChildrenIds/
│   │   │   │   │       │   ├── AddBlockMenu/
│   │   │   │   │       │   │   ├── BlockButton.tsx
│   │   │   │   │       │   │   ├── BlocksMenu.tsx
│   │   │   │   │       │   │   ├── DividerButton.tsx
│   │   │   │   │       │   │   ├── PlaceholderButton.tsx
│   │   │   │   │       │   │   ├── buttons.tsx
│   │   │   │   │       │   │   └── index.tsx
│   │   │   │   │       │   └── index.tsx
│   │   │   │   │       ├── TStyle.ts
│   │   │   │   │       ├── block-wrappers/
│   │   │   │   │       │   ├── EditorBlockWrapper.tsx
│   │   │   │   │       │   ├── ReaderBlockWrapper.tsx
│   │   │   │   │       │   └── TuneMenu.tsx
│   │   │   │   │       ├── fontFamily.ts
│   │   │   │   │       └── zod.ts
│   │   │   │   └── editor/
│   │   │   │       ├── EditorBlock.tsx
│   │   │   │       ├── EditorContext.tsx
│   │   │   │       └── core.tsx
│   │   │   ├── getConfiguration/
│   │   │   │   ├── index.tsx
│   │   │   │   └── sample/
│   │   │   │       └── empty-email-message.ts
│   │   │   ├── main.tsx
│   │   │   ├── theme.ts
│   │   │   ├── utils.tsx
│   │   │   └── vite-env.d.ts
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   ├── fontello/
│   │   └── config.json
│   ├── index.html
│   ├── jsconfig.json
│   ├── package.json
│   ├── public/
│   │   └── static/
│   │       └── tinymce/
│   │           └── lang/
│   │               ├── cs.js
│   │               ├── de.js
│   │               ├── es_419.js
│   │               ├── fr_FR.js
│   │               ├── it_IT.js
│   │               ├── pl.js
│   │               ├── pt_BR.js
│   │               ├── pt_PT.js
│   │               ├── ro.js
│   │               └── tr.js
│   ├── src/
│   │   ├── App.vue
│   │   ├── api/
│   │   │   └── index.js
│   │   ├── assets/
│   │   │   ├── icons/
│   │   │   │   └── fontello.css
│   │   │   └── style.scss
│   │   ├── components/
│   │   │   ├── BarChart.vue
│   │   │   ├── CampaignPreview.vue
│   │   │   ├── Chart.vue
│   │   │   ├── CodeEditor.vue
│   │   │   ├── CopyText.vue
│   │   │   ├── Editor.vue
│   │   │   ├── EmptyPlaceholder.vue
│   │   │   ├── ListSelector.vue
│   │   │   ├── LogView.vue
│   │   │   ├── Navigation.vue
│   │   │   ├── RichtextEditor.vue
│   │   │   ├── SubscriberActivity.vue
│   │   │   ├── VisualEditor.vue
│   │   │   ├── editor-theme.js
│   │   │   └── editor.js
│   │   ├── constants.js
│   │   ├── main.js
│   │   ├── router/
│   │   │   └── index.js
│   │   ├── store/
│   │   │   └── index.js
│   │   ├── utils.js
│   │   └── views/
│   │       ├── 404.vue
│   │       ├── About.vue
│   │       ├── Bounces.vue
│   │       ├── Campaign.vue
│   │       ├── CampaignAnalytics.vue
│   │       ├── Campaigns.vue
│   │       ├── Dashboard.vue
│   │       ├── Forms.vue
│   │       ├── Import.vue
│   │       ├── ListForm.vue
│   │       ├── Lists.vue
│   │       ├── Logs.vue
│   │       ├── Maintenance.vue
│   │       ├── Media.vue
│   │       ├── RoleForm.vue
│   │       ├── Roles.vue
│   │       ├── Settings.vue
│   │       ├── SubscriberBulkList.vue
│   │       ├── SubscriberForm.vue
│   │       ├── Subscribers.vue
│   │       ├── TemplateForm.vue
│   │       ├── Templates.vue
│   │       ├── UserForm.vue
│   │       ├── UserProfile.vue
│   │       ├── Users.vue
│   │       └── settings/
│   │           ├── appearance.vue
│   │           ├── bounces.vue
│   │           ├── general.vue
│   │           ├── media.vue
│   │           ├── messengers.vue
│   │           ├── performance.vue
│   │           ├── privacy.vue
│   │           ├── security.vue
│   │           └── smtp.vue
│   └── vite.config.js
├── go.mod
├── go.sum
├── i18n/
│   ├── bg.json
│   ├── ca.json
│   ├── cs-cz.json
│   ├── cy.json
│   ├── da.json
│   ├── de.json
│   ├── el.json
│   ├── en.json
│   ├── eo.json
│   ├── es.json
│   ├── fi.json
│   ├── fr-CA.json
│   ├── fr.json
│   ├── he.json
│   ├── hu.json
│   ├── it.json
│   ├── jp.json
│   ├── ko.json
│   ├── ml.json
│   ├── nl.json
│   ├── no.json
│   ├── pl.json
│   ├── pt-BR.json
│   ├── pt.json
│   ├── ro.json
│   ├── ru.json
│   ├── se.json
│   ├── sk.json
│   ├── sl.json
│   ├── tr.json
│   ├── uk.json
│   ├── vi.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── internal/
│   ├── auth/
│   │   ├── auth.go
│   │   └── models.go
│   ├── bounce/
│   │   ├── bounce.go
│   │   ├── mailbox/
│   │   │   ├── opt.go
│   │   │   └── pop.go
│   │   └── webhooks/
│   │       ├── forwardemail.go
│   │       ├── postmark.go
│   │       ├── sendgrid.go
│   │       └── ses.go
│   ├── buflog/
│   │   └── buflog.go
│   ├── captcha/
│   │   └── captcha.go
│   ├── core/
│   │   ├── bounces.go
│   │   ├── campaigns.go
│   │   ├── core.go
│   │   ├── dashboard.go
│   │   ├── lists.go
│   │   ├── media.go
│   │   ├── roles.go
│   │   ├── settings.go
│   │   ├── subscribers.go
│   │   ├── subscriptions.go
│   │   ├── templates.go
│   │   └── users.go
│   ├── events/
│   │   └── events.go
│   ├── i18n/
│   │   └── i18n.go
│   ├── manager/
│   │   ├── manager.go
│   │   ├── message.go
│   │   └── pipe.go
│   ├── media/
│   │   ├── media.go
│   │   └── providers/
│   │       ├── filesystem/
│   │       │   └── filesystem.go
│   │       └── s3/
│   │           └── s3.go
│   ├── messenger/
│   │   ├── email/
│   │   │   └── email.go
│   │   └── postback/
│   │       ├── postback.go
│   │       └── postback_easyjson.go
│   ├── migrations/
│   │   ├── v0.4.0.go
│   │   ├── v0.7.0.go
│   │   ├── v0.8.0.go
│   │   ├── v0.9.0.go
│   │   ├── v1.0.0.go
│   │   ├── v2.0.0.go
│   │   ├── v2.1.0.go
│   │   ├── v2.2.0.go
│   │   ├── v2.3.0.go
│   │   ├── v2.4.0.go
│   │   ├── v2.5.0.go
│   │   ├── v3.0.0.go
│   │   ├── v4.0.0.go
│   │   ├── v4.1.0.go
│   │   ├── v5.0.0.go
│   │   ├── v5.1.0.go
│   │   ├── v6.0.0.go
│   │   └── v6.1.0.go
│   ├── notifs/
│   │   └── notifs.go
│   ├── subimporter/
│   │   └── importer.go
│   ├── tmptokens/
│   │   └── tmptokens.go
│   └── utils/
│       └── utils.go
├── listmonk-simple.service
├── listmonk@.service
├── models/
│   ├── bounces.go
│   ├── campaigns.go
│   ├── common.go
│   ├── lists.go
│   ├── messages.go
│   ├── queries.go
│   ├── settings.go
│   ├── subscribers.go
│   └── templates.go
├── permissions.json
├── project.inlang.json
├── queries/
│   ├── bounces.sql
│   ├── campaigns.sql
│   ├── links.sql
│   ├── lists.sql
│   ├── media.sql
│   ├── misc.sql
│   ├── roles.sql
│   ├── subscribers.sql
│   ├── templates.sql
│   └── users.sql
├── schema.sql
├── scripts/
│   ├── refresh-i18n.sh
│   └── translate-i18n.py
└── static/
    ├── email-templates/
    │   ├── base.html
    │   ├── campaign-status.html
    │   ├── default-archive.tpl
    │   ├── default-visual.json
    │   ├── default-visual.tpl
    │   ├── default.tpl
    │   ├── forgot-password.html
    │   ├── import-status.html
    │   ├── sample-tx.tpl
    │   ├── smtp-test.html
    │   ├── subscriber-data.html
    │   ├── subscriber-optin-campaign.html
    │   └── subscriber-optin.html
    └── public/
        ├── static/
        │   ├── script.js
        │   └── style.css
        └── templates/
            ├── archive.html
            ├── forgot-password.html
            ├── home.html
            ├── index.html
            ├── login-setup.html
            ├── login.html
            ├── message.html
            ├── optin.html
            ├── reset-password.html
            ├── subscription-form.html
            ├── subscription.html
            └── twofa.html
Download .txt
SYMBOL INDEX (1091 symbols across 172 files)

FILE: cmd/admin.go
  type serverConfig (line 15) | type serverConfig struct
  method GetServerConfig (line 41) | func (a *App) GetServerConfig(c echo.Context) error {
  method GetDashboardCharts (line 94) | func (a *App) GetDashboardCharts(c echo.Context) error {
  method GetDashboardCounts (line 105) | func (a *App) GetDashboardCounts(c echo.Context) error {
  method ReloadApp (line 116) | func (a *App) ReloadApp(c echo.Context) error {

FILE: cmd/archive.go
  type campArchive (line 17) | type campArchive struct
  method GetCampaignArchives (line 27) | func (a *App) GetCampaignArchives(c echo.Context) error {
  method GetCampaignArchivesFeed (line 53) | func (a *App) GetCampaignArchivesFeed(c echo.Context) error {
  method CampaignArchivesPage (line 99) | func (a *App) CampaignArchivesPage(c echo.Context) error {
  method CampaignArchivePage (line 119) | func (a *App) CampaignArchivePage(c echo.Context) error {
  method CampaignArchivePageLatest (line 177) | func (a *App) CampaignArchivePageLatest(c echo.Context) error {
  method getCampaignArchives (line 194) | func (a *App) getCampaignArchives(offset, limit int, renderBody bool) ([...
  method compileArchiveCampaigns (line 239) | func (a *App) compileArchiveCampaigns(camps []models.Campaign) ([]manage...

FILE: cmd/auth.go
  constant passwordResetTTL (line 29) | passwordResetTTL = 30 * time.Minute
  constant twofaTokenTTL (line 30) | twofaTokenTTL    = 5 * time.Minute
  constant tmpAuthTokenLen (line 33) | tmpAuthTokenLen = 64
  type loginTpl (line 36) | type loginTpl struct
  type oidcState (line 48) | type oidcState struct
  type forgotPasswordTpl (line 53) | type forgotPasswordTpl struct
  type resetPasswordTpl (line 59) | type resetPasswordTpl struct
  type twofaTpl (line 67) | type twofaTpl struct
  method LoginPage (line 85) | func (a *App) LoginPage(c echo.Context) error {
  method LoginSetupPage (line 109) | func (a *App) LoginSetupPage(c echo.Context) error {
  method TwofaPage (line 127) | func (a *App) TwofaPage(c echo.Context) error {
  method Logout (line 168) | func (a *App) Logout(c echo.Context) error {
  method OIDCLogin (line 177) | func (a *App) OIDCLogin(c echo.Context) error {
  method OIDCFinish (line 204) | func (a *App) OIDCFinish(c echo.Context) error {
  method ForgotPage (line 275) | func (a *App) ForgotPage(c echo.Context) error {
  method ResetPage (line 287) | func (a *App) ResetPage(c echo.Context) error {
  method renderLoginPage (line 320) | func (a *App) renderLoginPage(c echo.Context, loginErr error) error {
  method renderLoginSetupPage (line 395) | func (a *App) renderLoginSetupPage(c echo.Context, loginErr error) error {
  method createOIDCUser (line 420) | func (a *App) createOIDCUser(claims auth.OIDCclaim, c echo.Context) (aut...
  method doLogin (line 450) | func (a *App) doLogin(c echo.Context) error {
  method doFirstTimeSetup (line 503) | func (a *App) doFirstTimeSetup(c echo.Context) error {
  method renderResetPasswordPage (line 570) | func (a *App) renderResetPasswordPage(c echo.Context, token, email, errM...
  method doForgotPassword (line 581) | func (a *App) doForgotPassword(c echo.Context) error {
  method doResetPassword (line 648) | func (a *App) doResetPassword(c echo.Context, token, email string) error {
  method renderTwofaPage (line 700) | func (a *App) renderTwofaPage(c echo.Context, token, next, errMsg string...
  method doTwofaVerify (line 712) | func (a *App) doTwofaVerify(c echo.Context, token string, userID int, ne...
  method GenerateTOTPQR (line 750) | func (a *App) GenerateTOTPQR(c echo.Context) error {

FILE: cmd/bounce.go
  method GetBounce (line 15) | func (a *App) GetBounce(c echo.Context) error {
  method GetBounces (line 27) | func (a *App) GetBounces(c echo.Context) error {
  method GetSubscriberBounces (line 59) | func (a *App) GetSubscriberBounces(c echo.Context) error {
  method DeleteBounces (line 71) | func (a *App) DeleteBounces(c echo.Context) error {
  method DeleteBounce (line 97) | func (a *App) DeleteBounce(c echo.Context) error {
  method BlocklistBouncedSubscribers (line 108) | func (a *App) BlocklistBouncedSubscribers(c echo.Context) error {
  method BounceWebhook (line 117) | func (a *App) BounceWebhook(c echo.Context) error {
  method validateBounceFields (line 237) | func (a *App) validateBounceFields(b models.Bounce) (models.Bounce, erro...

FILE: cmd/campaigns.go
  type campReq (line 26) | type campReq struct
  type campContentReq (line 43) | type campContentReq struct
  method GetCampaigns (line 55) | func (a *App) GetCampaigns(c echo.Context) error {
  method GetCampaign (line 112) | func (a *App) GetCampaign(c echo.Context) error {
  method PreviewCampaign (line 137) | func (a *App) PreviewCampaign(c echo.Context) error {
  method PreviewCampaignArchive (line 199) | func (a *App) PreviewCampaignArchive(c echo.Context) error {
  method CampaignContent (line 237) | func (a *App) CampaignContent(c echo.Context) error {
  method CreateCampaign (line 254) | func (a *App) CreateCampaign(c echo.Context) error {
  method UpdateCampaign (line 301) | func (a *App) UpdateCampaign(c echo.Context) error {
  method UpdateCampaignStatus (line 348) | func (a *App) UpdateCampaignStatus(c echo.Context) error {
  method UpdateCampaignArchive (line 379) | func (a *App) UpdateCampaignArchive(c echo.Context) error {
  method DeleteCampaign (line 414) | func (a *App) DeleteCampaign(c echo.Context) error {
  method DeleteCampaigns (line 432) | func (a *App) DeleteCampaigns(c echo.Context) error {
  method GetRunningCampaignStats (line 482) | func (a *App) GetRunningCampaignStats(c echo.Context) error {
  method TestCampaign (line 516) | func (a *App) TestCampaign(c echo.Context) error {
  method GetCampaignViewAnalytics (line 590) | func (a *App) GetCampaignViewAnalytics(c echo.Context) error {
  method sendTestMessage (line 631) | func (a *App) sendTestMessage(sub models.Subscriber, camp *models.Campai...
  method validateCampaignFields (line 649) | func (a *App) validateCampaignFields(c campReq) (campReq, error) {
  method makeOptinCampaignMessage (line 736) | func (a *App) makeOptinCampaignMessage(o campReq) (campReq, error) {
  method checkCampaignPerm (line 779) | func (a *App) checkCampaignPerm(types auth.PermType, id int, c echo.Cont...
  function canEditCampaign (line 816) | func canEditCampaign(status string) bool {

FILE: cmd/events.go
  method EventStream (line 14) | func (a *App) EventStream(c echo.Context) error {

FILE: cmd/handlers.go
  constant stdInputMaxLen (line 18) | stdInputMaxLen = 2000
  constant uriAdmin (line 21) | uriAdmin = "/admin"
  type okResp (line 24) | type okResp struct
  function initHTTPHandlers (line 33) | func initHTTPHandlers(e *echo.Echo, a *App) {
  method AdminPage (line 307) | func (a *App) AdminPage(c echo.Context) error {
  method HealthCheck (line 319) | func (a *App) HealthCheck(c echo.Context) error {
  function serveCustomAppearance (line 325) | func serveCustomAppearance(name string) echo.HandlerFunc {
  method hasUUID (line 357) | func (a *App) hasUUID(next echo.HandlerFunc, params ...string) echo.Hand...
  function hasID (line 370) | func hasID(next echo.HandlerFunc) echo.HandlerFunc {
  method hasSub (line 384) | func (a *App) hasSub(next echo.HandlerFunc) echo.HandlerFunc {
  function noIndex (line 404) | func noIndex(next echo.HandlerFunc) echo.HandlerFunc {
  function getID (line 412) | func getID(c echo.Context) int {

FILE: cmd/i18n.go
  type i18nLang (line 15) | type i18nLang struct
  type i18nLangRaw (line 20) | type i18nLangRaw struct
  method GetI18nLang (line 28) | func (a *App) GetI18nLang(c echo.Context) error {
  function getI18nLangList (line 43) | func getI18nLangList(fs stuffbin.FileSystem) ([]i18nLang, error) {
  function getI18nLang (line 75) | func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, bool,...

FILE: cmd/import.go
  method ImportSubscribers (line 17) | func (a *App) ImportSubscribers(c echo.Context) error {
  method GetImportSubscribers (line 112) | func (a *App) GetImportSubscribers(c echo.Context) error {
  method GetImportSubscriberStats (line 118) | func (a *App) GetImportSubscriberStats(c echo.Context) error {
  method StopImportSubscribers (line 125) | func (a *App) StopImportSubscribers(c echo.Context) error {

FILE: cmd/init.go
  constant queryFilePath (line 60) | queryFilePath = "/queries"
  constant emailMsgr (line 62) | emailMsgr = "email"
  type UrlConfig (line 66) | type UrlConfig struct
  type Config (line 80) | type Config struct
  function initFlags (line 155) | func initFlags(ko *koanf.Koanf) {
  function initConfigFiles (line 185) | func initConfigFiles(files []string, ko *koanf.Koanf) {
  function initFS (line 199) | func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.Fil...
  function initDB (line 310) | func initDB() *sqlx.DB {
  function readQueries (line 341) | func readQueries(dir string, fs stuffbin.FileSystem) goyesql.Queries {
  function prepareQueries (line 373) | func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) ...
  function initSettings (line 404) | func initSettings(query string, db *sqlx.DB, ko *koanf.Koanf) {
  function initUrlConfig (line 428) | func initUrlConfig(ko *koanf.Koanf) *UrlConfig {
  function initConstConfig (line 459) | func initConstConfig(ko *koanf.Koanf) *Config {
  function initI18n (line 522) | func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
  function initCore (line 535) | func initCore(fnNotify func(sub models.Subscriber, listIDs []int) (int, ...
  function initCampaignManager (line 559) | func initCampaignManager(msgrs []manager.Messenger, q *models.Queries, u...
  function initTxTemplates (line 596) | func initTxTemplates(m *manager.Manager, co *core.Core) {
  function initImporter (line 613) | func initImporter(q *models.Queries, db *sqlx.DB, core *core.Core, i *i1...
  function initSMTPMessengers (line 636) | func initSMTPMessengers() []manager.Messenger {
  function initPostbackMessengers (line 687) | func initPostbackMessengers(ko *koanf.Koanf) []manager.Messenger {
  function initMediaStore (line 722) | func initMediaStore(ko *koanf.Koanf) media.Store {
  function initNotifs (line 757) | func initNotifs(fs stuffbin.FileSystem, i *i18n.I18n, em *email.Emailer,...
  function initBounceManager (line 790) | func initBounceManager(cb func(models.Bounce) error, stmt *sqlx.Stmt, lo...
  function initAbout (line 842) | func initAbout(q *models.Queries, db *sqlx.DB) about {
  function initHTTPServer (line 879) | func initHTTPServer(cfg *Config, urlCfg *UrlConfig, i *i18n.I18n, fs stu...
  function initCaptcha (line 948) | func initCaptcha() *captcha.Captcha {
  function initCron (line 958) | func initCron(co *core.Core, db *sqlx.DB) {
  function awaitReload (line 1003) | func awaitReload(sigChan chan os.Signal, closerWait chan bool, closer fu...
  function initTplFuncs (line 1037) | func initTplFuncs(i *i18n.I18n, u *UrlConfig) template.FuncMap {
  function initAuth (line 1071) | func initAuth(co *core.Core, db *sql.DB, ko *koanf.Koanf) (bool, *auth.A...
  function joinFSPaths (line 1146) | func joinFSPaths(root string, paths []string) []string {

FILE: cmd/install.go
  function install (line 20) | func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt...
  function installSchema (line 121) | func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) e...
  function installLists (line 135) | func installLists(q *models.Queries) (int, int) {
  function installSubs (line 166) | func installSubs(defListID, optinListID int, q *models.Queries) {
  function installTemplates (line 190) | func installTemplates(q *models.Queries) (int, int) {
  function installCampaign (line 243) | func installCampaign(campTplID, archiveTplID int, q *models.Queries) {
  function recordMigrationVersion (line 281) | func recordMigrationVersion(ver string, db *sqlx.DB) error {
  function newConfigFile (line 288) | func newConfigFile(path string) error {
  function checkSchema (line 305) | func checkSchema(db *sqlx.DB) (bool, error) {
  function installUser (line 315) | func installUser(username, password, apiUsername string, q *models.Queri...

FILE: cmd/lists.go
  method GetLists (line 14) | func (a *App) GetLists(c echo.Context) error {
  method GetList (line 75) | func (a *App) GetList(c echo.Context) error {
  method CreateList (line 95) | func (a *App) CreateList(c echo.Context) error {
  method UpdateList (line 116) | func (a *App) UpdateList(c echo.Context) error {
  method DeleteList (line 147) | func (a *App) DeleteList(c echo.Context) error {
  method DeleteLists (line 166) | func (a *App) DeleteLists(c echo.Context) error {

FILE: cmd/main.go
  type App (line 35) | type App struct
  function init (line 96) | func init() {
  function main (line 193) | func main() {

FILE: cmd/maintenance.go
  method GCSubscribers (line 13) | func (a *App) GCSubscribers(c echo.Context) error {
  method GCSubscriptions (line 40) | func (a *App) GCSubscriptions(c echo.Context) error {
  method GCCampaignAnalytics (line 59) | func (a *App) GCCampaignAnalytics(c echo.Context) error {
  function RunDBVacuum (line 89) | func RunDBVacuum(db *sqlx.DB, lo *log.Logger) {

FILE: cmd/manager_store.go
  type store (line 14) | type store struct
    method NextCampaigns (line 39) | func (s *store) NextCampaigns(currentIDs []int64, sentCounts []int64) ...
    method NextSubscribers (line 49) | func (s *store) NextSubscribers(campID, limit int) ([]models.Subscribe...
    method GetCampaign (line 70) | func (s *store) GetCampaign(campID int) (*models.Campaign, error) {
    method UpdateCampaignStatus (line 77) | func (s *store) UpdateCampaignStatus(campID int, status string) error {
    method UpdateCampaignCounts (line 83) | func (s *store) UpdateCampaignCounts(campID int, toSend int, sent int,...
    method GetAttachment (line 89) | func (s *store) GetAttachment(mediaID int) (models.Attachment, error) {
    method CreateLink (line 108) | func (s *store) CreateLink(url string) (string, error) {
    method RecordBounce (line 125) | func (s *store) RecordBounce(b models.Bounce) (int64, int, error) {
    method BlocklistSubscriber (line 143) | func (s *store) BlocklistSubscriber(id int64) error {
    method DeleteSubscriber (line 149) | func (s *store) DeleteSubscriber(id int64) error {
  type runningCamp (line 20) | type runningCamp struct
  function newManagerStore (line 28) | func newManagerStore(q *models.Queries, c *core.Core, m media.Store) *st...

FILE: cmd/media.go
  constant thumbPrefix (line 16) | thumbPrefix   = "thumb_"
  constant thumbnailSize (line 17) | thumbnailSize = 250
  method UploadMedia (line 26) | func (a *App) UploadMedia(c echo.Context) error {
  method GetAllMedia (line 143) | func (a *App) GetAllMedia(c echo.Context) error {
  method GetMedia (line 166) | func (a *App) GetMedia(c echo.Context) error {
  method DeleteMedia (line 178) | func (a *App) DeleteMedia(c echo.Context) error {
  method ServeS3Media (line 195) | func (a *App) ServeS3Media(c echo.Context) error {
  function processImage (line 212) | func processImage(file *multipart.FileHeader) (*bytes.Reader, int, int, ...

FILE: cmd/public.go
  constant tplMessage (line 25) | tplMessage = "message"
  type tplRenderer (line 29) | type tplRenderer struct
    method Render (line 106) | func (t *tplRenderer) Render(w io.Writer, name string, data any, c ech...
  type tplData (line 43) | type tplData struct
  type publicTpl (line 56) | type publicTpl struct
  type unsubTpl (line 61) | type unsubTpl struct
  type optinReq (line 73) | type optinReq struct
  type optinTpl (line 79) | type optinTpl struct
  type msgTpl (line 84) | type msgTpl struct
  type subFormTpl (line 90) | type subFormTpl struct
  method GetPublicLists (line 123) | func (a *App) GetPublicLists(c echo.Context) error {
  method ViewCampaignMessage (line 148) | func (a *App) ViewCampaignMessage(c echo.Context) error {
  method SubscriptionPage (line 197) | func (a *App) SubscriptionPage(c echo.Context) error {
  method SubscriptionPrefs (line 253) | func (a *App) SubscriptionPrefs(c echo.Context) error {
  method OptinPage (line 348) | func (a *App) OptinPage(c echo.Context) error {
  method SubscriptionFormPage (line 413) | func (a *App) SubscriptionFormPage(c echo.Context) error {
  method SubscriptionForm (line 452) | func (a *App) SubscriptionForm(c echo.Context) error {
  method PublicSubscription (line 516) | func (a *App) PublicSubscription(c echo.Context) error {
  method LinkRedirect (line 534) | func (a *App) LinkRedirect(c echo.Context) error {
  method RegisterCampaignView (line 569) | func (a *App) RegisterCampaignView(c echo.Context) error {
  method SelfExportSubscriberData (line 598) | func (a *App) SelfExportSubscriberData(c echo.Context) error {
  method WipeSubscriberData (line 654) | func (a *App) WipeSubscriberData(c echo.Context) error {
  method AltchaChallenge (line 673) | func (a *App) AltchaChallenge(c echo.Context) error {
  function drawTransparentImage (line 693) | func drawTransparentImage(h, w int) []byte {
  method processSubForm (line 706) | func (a *App) processSubForm(c echo.Context) (bool, error) {

FILE: cmd/roles.go
  method GetUserRoles (line 13) | func (a *App) GetUserRoles(c echo.Context) error {
  method GeListRoles (line 24) | func (a *App) GeListRoles(c echo.Context) error {
  method CreateUserRole (line 35) | func (a *App) CreateUserRole(c echo.Context) error {
  method CreateListRole (line 54) | func (a *App) CreateListRole(c echo.Context) error {
  method UpdateUserRole (line 73) | func (a *App) UpdateUserRole(c echo.Context) error {
  method UpdateListRole (line 108) | func (a *App) UpdateListRole(c echo.Context) error {
  method DeleteRole (line 145) | func (a *App) DeleteRole(c echo.Context) error {
  method validateUserRole (line 167) | func (a *App) validateUserRole(r auth.Role) error {
  method validateListRole (line 181) | func (a *App) validateListRole(r auth.ListRole) error {

FILE: cmd/settings.go
  constant pwdMask (line 29) | pwdMask = "•"
  type aboutHost (line 31) | type aboutHost struct
  type aboutSystem (line 37) | type aboutSystem struct
  type about (line 43) | type about struct
  method GetSettings (line 58) | func (a *App) GetSettings(c echo.Context) error {
  method UpdateSettings (line 86) | func (a *App) UpdateSettings(c echo.Context) error {
  method UpdateSettingsByKey (line 298) | func (a *App) UpdateSettingsByKey(c echo.Context) error {
  method handleSettingsRestart (line 320) | func (a *App) handleSettingsRestart(c echo.Context) error {
  method GetLogs (line 343) | func (a *App) GetLogs(c echo.Context) error {
  method TestSMTPSettings (line 348) | func (a *App) TestSMTPSettings(c echo.Context) error {
  method GetAboutInfo (line 403) | func (a *App) GetAboutInfo(c echo.Context) error {

FILE: cmd/subscribers.go
  constant dummyUUID (line 23) | dummyUUID = "00000000-0000-0000-0000-000000000000"
  type subQueryReq (line 28) | type subQueryReq struct
  type subOptin (line 41) | type subOptin struct
  method GetSubscriber (line 59) | func (a *App) GetSubscriber(c echo.Context) error {
  method GetSubscriberActivity (line 78) | func (a *App) GetSubscriberActivity(c echo.Context) error {
  method QuerySubscribers (line 97) | func (a *App) QuerySubscribers(c echo.Context) error {
  method ExportSubscribers (line 143) | func (a *App) ExportSubscribers(c echo.Context) error {
  method CreateSubscriber (line 219) | func (a *App) CreateSubscriber(c echo.Context) error {
  method UpdateSubscriber (line 253) | func (a *App) UpdateSubscriber(c echo.Context) error {
  method SubscriberSendOptin (line 305) | func (a *App) SubscriberSendOptin(c echo.Context) error {
  method BlocklistSubscriber (line 322) | func (a *App) BlocklistSubscriber(c echo.Context) error {
  method BlocklistSubscribers (line 333) | func (a *App) BlocklistSubscribers(c echo.Context) error {
  method ManageSubscriberLists (line 355) | func (a *App) ManageSubscriberLists(c echo.Context) error {
  method DeleteSubscriber (line 416) | func (a *App) DeleteSubscriber(c echo.Context) error {
  method DeleteSubscribers (line 427) | func (a *App) DeleteSubscribers(c echo.Context) error {
  method DeleteSubscribersByQuery (line 449) | func (a *App) DeleteSubscribersByQuery(c echo.Context) error {
  method BlocklistSubscribersByQuery (line 486) | func (a *App) BlocklistSubscribersByQuery(c echo.Context) error {
  method ManageSubscriberListsByQuery (line 522) | func (a *App) ManageSubscriberListsByQuery(c echo.Context) error {
  method DeleteSubscriberBounces (line 571) | func (a *App) DeleteSubscriberBounces(c echo.Context) error {
  method ExportSubscriberData (line 585) | func (a *App) ExportSubscriberData(c echo.Context) error {
  method exportSubscriberData (line 607) | func (a *App) exportSubscriberData(id int, subUUID string, exportables m...
  method hasSubPerm (line 639) | func (a *App) hasSubPerm(u auth.User, subIDs []int) error {
  method filterListQueryByPerm (line 663) | func (a *App) filterListQueryByPerm(param string, qp url.Values, user au...
  function formatSQLExp (line 694) | func formatSQLExp(q string) string {
  function makeOptinNotifyHook (line 710) | func makeOptinNotifyHook(unsubHeader bool, u *UrlConfig, q *models.Queri...

FILE: cmd/templates.go
  constant tplTag (line 18) | tplTag = `{{ template "content" . }}`
  constant dummyTpl (line 20) | dummyTpl = `
  method GetTemplate (line 35) | func (a *App) GetTemplate(c echo.Context) error {
  method GetTemplates (line 50) | func (a *App) GetTemplates(c echo.Context) error {
  method PreviewTemplate (line 64) | func (a *App) PreviewTemplate(c echo.Context) error {
  method PreviewTemplateBody (line 82) | func (a *App) PreviewTemplateBody(c echo.Context) error {
  method CreateTemplate (line 108) | func (a *App) CreateTemplate(c echo.Context) error {
  method UpdateTemplate (line 148) | func (a *App) UpdateTemplate(c echo.Context) error {
  method TemplateSetDefault (line 189) | func (a *App) TemplateSetDefault(c echo.Context) error {
  method DeleteTemplate (line 200) | func (a *App) DeleteTemplate(c echo.Context) error {
  method validateTemplate (line 214) | func (a *App) validateTemplate(o models.Template) error {
  method previewTemplate (line 233) | func (a *App) previewTemplate(tpl models.Template) ([]byte, error) {

FILE: cmd/tx.go
  method SendTxMessage (line 17) | func (a *App) SendTxMessage(c echo.Context) error {
  method validateTxMessage (line 179) | func (a *App) validateTxMessage(m models.TxMessage) (models.TxMessage, e...

FILE: cmd/updates.go
  constant updateCheckURL (line 13) | updateCheckURL = "https://update.listmonk.app/update.json"
  type AppUpdate (line 15) | type AppUpdate struct
  method checkUpdates (line 39) | func (a *App) checkUpdates(curVersion string, interval time.Duration) {

FILE: cmd/upgrade.go
  type migFunc (line 20) | type migFunc struct
  function upgrade (line 52) | func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool, record bo...
  function checkUpgrade (line 102) | func checkUpgrade(db *sqlx.DB) {
  function getPendingMigrations (line 124) | func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) {
  function getLastMigrationVersion (line 145) | func getLastMigrationVersion(db *sqlx.DB) (string, error) {
  function isTableNotExistErr (line 161) | func isTableNotExistErr(err error) bool {

FILE: cmd/users.go
  method GetUser (line 22) | func (a *App) GetUser(c echo.Context) error {
  method GetUsers (line 37) | func (a *App) GetUsers(c echo.Context) error {
  method CreateUser (line 53) | func (a *App) CreateUser(c echo.Context) error {
  method UpdateUser (line 107) | func (a *App) UpdateUser(c echo.Context) error {
  method DeleteUser (line 185) | func (a *App) DeleteUser(c echo.Context) error {
  method DeleteUsers (line 201) | func (a *App) DeleteUsers(c echo.Context) error {
  method GetUserProfile (line 221) | func (a *App) GetUserProfile(c echo.Context) error {
  method UpdateUserProfile (line 233) | func (a *App) UpdateUserProfile(c echo.Context) error {
  method EnableTOTP (line 273) | func (a *App) EnableTOTP(c echo.Context) error {
  method DisableTOTP (line 309) | func (a *App) DisableTOTP(c echo.Context) error {
  function cacheUsers (line 341) | func cacheUsers(co *core.Core, a *auth.Auth) (bool, error) {

FILE: cmd/utils.go
  function inArray (line 19) | func inArray(val string, vals []string) (ok bool) {
  function makeFilename (line 24) | func makeFilename(fName string) string {
  function appendSuffixToFilename (line 35) | func appendSuffixToFilename(filename, suffix string) string {
  function makeMsgTpl (line 44) | func makeMsgTpl(pageTitle, heading, msg string) msgTpl {
  function parseStringIDs (line 58) | func parseStringIDs(s []string) ([]int, error) {
  function generateRandomString (line 77) | func generateRandomString(n int) (string, error) {
  function strHasLen (line 92) | func strHasLen(str string, min, max int) bool {
  function getQueryInts (line 97) | func getQueryInts(param string, qp url.Values) ([]int, error) {

FILE: docs/i18n/main.js
  constant BASEURL (line 1) | const BASEURL = "https://raw.githubusercontent.com/knadh/listmonk/master...
  constant BASELANG (line 2) | const BASELANG = "en";
  method init (line 19) | init() {
  method loadBaseLang (line 24) | loadBaseLang(url) {
  method populateData (line 60) | populateData(data) {
  method loadLanguage (line 72) | loadLanguage(lang) {
  method saveData (line 81) | saveData() {
  method isDone (line 86) | isDone(key) {
  method isItemVisible (line 90) | isItemVisible(key) {
  method onToggleRaw (line 94) | onToggleRaw() {
  method onLoadLanguage (line 109) | onLoadLanguage() {
  method onNewLang (line 117) | onNewLang() {
  method onDownloadJSON (line 128) | onDownloadJSON() {
  method mounted (line 144) | mounted() {
  method view (line 149) | view(v) {
  method completed (line 174) | completed() {

FILE: frontend/cypress.config.js
  method setupNodeEvents (line 21) | setupNodeEvents(on, config) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/index.tsx
  function renderMessage (line 20) | function renderMessage(val: string) {
  function ConfigurationPanel (line 28) | function ConfigurationPanel() {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/AvatarSidebarPanel.tsx
  type AvatarSidebarPanelProps (line 13) | type AvatarSidebarPanelProps = {
  function AvatarSidebarPanel (line 17) | function AvatarSidebarPanel({ data, setData }: AvatarSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ButtonSidebarPanel.tsx
  type ButtonSidebarPanelProps (line 12) | type ButtonSidebarPanelProps = {
  function ButtonSidebarPanel (line 16) | function ButtonSidebarPanel({ data, setData }: ButtonSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ColumnsContainerSidebarPanel.tsx
  type ColumnsContainerPanelProps (line 21) | type ColumnsContainerPanelProps = {
  function ColumnsContainerPanel (line 25) | function ColumnsContainerPanel({ data, setData }: ColumnsContainerPanelP...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ContainerSidebarPanel.tsx
  type ContainerSidebarPanelProps (line 8) | type ContainerSidebarPanelProps = {
  function ContainerSidebarPanel (line 13) | function ContainerSidebarPanel({ data, setData }: ContainerSidebarPanelP...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/DividerSidebarPanel.tsx
  type DividerSidebarPanelProps (line 11) | type DividerSidebarPanelProps = {
  function DividerSidebarPanel (line 15) | function DividerSidebarPanel({ data, setData }: DividerSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/EmailLayoutSidebarPanel.tsx
  type EmailLayoutSidebarFieldsProps (line 14) | type EmailLayoutSidebarFieldsProps = {
  function EmailLayoutSidebarFields (line 18) | function EmailLayoutSidebarFields({ data, setData }: EmailLayoutSidebarF...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/HeadingSidebarPanel.tsx
  type HeadingSidebarPanelProps (line 11) | type HeadingSidebarPanelProps = {
  function HeadingSidebarPanel (line 15) | function HeadingSidebarPanel({ data, setData }: HeadingSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/HtmlSidebarPanel.tsx
  type HtmlSidebarPanelProps (line 9) | type HtmlSidebarPanelProps = {
  function HtmlSidebarPanel (line 13) | function HtmlSidebarPanel({ data, setData }: HtmlSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ImageSidebarPanel.tsx
  type ImageSidebarPanelProps (line 17) | type ImageSidebarPanelProps = {
  function ImageSidebarPanel (line 21) | function ImageSidebarPanel({ data, setData }: ImageSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/SpacerSidebarPanel.tsx
  type SpacerSidebarPanelProps (line 9) | type SpacerSidebarPanelProps = {
  function SpacerSidebarPanel (line 13) | function SpacerSidebarPanel({ data, setData }: SpacerSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/TextSidebarPanel.tsx
  type TextSidebarPanelProps (line 10) | type TextSidebarPanelProps = {
  function TextSidebarPanel (line 14) | function TextSidebarPanel({ data, setData }: TextSidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/BaseSidebarPanel.tsx
  type SidebarPanelProps (line 5) | type SidebarPanelProps = {
  function BaseSidebarPanel (line 9) | function BaseSidebarPanel({ title, children }: SidebarPanelProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/BooleanInput.tsx
  type Props (line 5) | type Props = {
  function BooleanInput (line 11) | function BooleanInput({ label, defaultValue, onChange }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/BaseColorInput.tsx
  constant BUTTON_SX (line 8) | const BUTTON_SX = {
  type Props (line 17) | type Props =
  function ColorInput (line 30) | function ColorInput({ label, defaultValue, onChange, nullable }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Picker.tsx
  constant DEFAULT_PRESET_COLORS (line 8) | const DEFAULT_PRESET_COLORS = [
  type Props (line 72) | type Props = {
  function Picker (line 76) | function Picker({ value, onChange }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Swatch.tsx
  type Props (line 5) | type Props = {
  constant TILE_BUTTON (line 11) | const TILE_BUTTON: SxProps = {
  function Swatch (line 15) | function Swatch({ paletteColors, value, onChange }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/index.tsx
  type Props (line 5) | type Props = {
  function ColorInput (line 10) | function ColorInput(props: Props) {
  type NullableProps (line 14) | type NullableProps = {
  function NullableColorInput (line 19) | function NullableColorInput(props: NullableProps) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColumnWidthsInput.tsx
  constant DEFAULT_2_COLUMNS (line 7) | const DEFAULT_2_COLUMNS = [6] as [number];
  constant DEFAULT_3_COLUMNS (line 8) | const DEFAULT_3_COLUMNS = [4, 8] as [number, number];
  type TWidthValue (line 10) | type TWidthValue = number | null | undefined;
  type FixedWidths (line 11) | type FixedWidths = [
  type ColumnsLayoutInputProps (line 17) | type ColumnsLayoutInputProps = {
  function ColumnWidthsInput (line 21) | function ColumnWidthsInput({ defaultValue, onChange }: ColumnsLayoutInpu...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontFamily.tsx
  constant OPTIONS (line 7) | const OPTIONS = FONT_FAMILIES.map((option) => (
  type NullableProps (line 13) | type NullableProps = {
  function NullableFontFamily (line 18) | function NullableFontFamily({ label, onChange, defaultValue }: NullableP...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontSizeInput.tsx
  type Props (line 8) | type Props = {
  function FontSizeInput (line 13) | function FontSizeInput({ label, defaultValue, onChange }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontWeightInput.tsx
  type Props (line 7) | type Props = {
  function FontWeightInput (line 12) | function FontWeightInput({ label, defaultValue, onChange }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/PaddingInput.tsx
  type TPaddingValue (line 13) | type TPaddingValue = {
  type Props (line 19) | type Props = {
  function PaddingInput (line 24) | function PaddingInput({ label, defaultValue, onChange }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/RadioGroupInput.tsx
  type Props (line 5) | type Props = {
  function RadioGroupInput (line 11) | function RadioGroupInput({ label, children, defaultValue, onChange }: Pr...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/SliderInput.tsx
  type SliderInputProps (line 7) | type SliderInputProps = {
  function SliderInput (line 21) | function SliderInput({ label, defaultValue, onChange, ...props }: Slider...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextAlignInput.tsx
  type Props (line 8) | type Props = {
  function TextAlignInput (line 13) | function TextAlignInput({ label, defaultValue, onChange }: Props) {

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextDimensionInput.tsx
  type TextDimensionInputProps (line 5) | type TextDimensionInputProps = {
  function TextDimensionInput (line 10) | function TextDimensionInput({ label, defaultValue, onChange }: TextDimen...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextInput.tsx
  type Props (line 5) | type Props = {
  function TextInput (line 15) | function TextInput({ helperText, label, placeholder, rows, InputProps, d...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/raw/RawSliderInput.tsx
  type SliderInputProps (line 5) | type SliderInputProps = {
  function RawSliderInput (line 18) | function RawSliderInput({ iconLabel, value, setValue, units, ...props }:...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/style-inputs/MultiStylePropertyPanel.tsx
  type MultiStylePropertyPanelProps (line 7) | type MultiStylePropertyPanelProps = {
  function MultiStylePropertyPanel (line 12) | function MultiStylePropertyPanel({ names, value, onChange }: MultiStyleP...

FILE: frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/style-inputs/SingleStylePropertyPanel.tsx
  type StylePropertyPanelProps (line 14) | type StylePropertyPanelProps = {
  function SingleStylePropertyPanel (line 19) | function SingleStylePropertyPanel({ name, value, onChange }: StyleProper...

FILE: frontend/email-builder/src/App/InspectorDrawer/StylesPanel.tsx
  function StylesPanel (line 7) | function StylesPanel() {

FILE: frontend/email-builder/src/App/InspectorDrawer/ToggleInspectorPanelButton.tsx
  function ToggleInspectorPanelButton (line 8) | function ToggleInspectorPanelButton() {

FILE: frontend/email-builder/src/App/InspectorDrawer/index.tsx
  constant INSPECTOR_DRAWER_WIDTH (line 12) | const INSPECTOR_DRAWER_WIDTH = 320;
  function InspectorDrawer (line 14) | function InspectorDrawer() {

FILE: frontend/email-builder/src/App/TemplatePanel/DownloadJson/index.tsx
  function DownloadJson (line 8) | function DownloadJson() {

FILE: frontend/email-builder/src/App/TemplatePanel/HtmlPanel.tsx
  function HtmlPanel (line 8) | function HtmlPanel() {

FILE: frontend/email-builder/src/App/TemplatePanel/ImportJson/ImportJsonDialog.tsx
  type ImportJsonDialogProps (line 19) | type ImportJsonDialogProps = {
  function ImportJsonDialog (line 22) | function ImportJsonDialog({ onClose }: ImportJsonDialogProps) {

FILE: frontend/email-builder/src/App/TemplatePanel/ImportJson/index.tsx
  function ImportJson (line 8) | function ImportJson() {

FILE: frontend/email-builder/src/App/TemplatePanel/ImportJson/validateJsonStringValue.ts
  type TResult (line 3) | type TResult = { error: string; data?: undefined } | { data: TEditorConf...
  function validateTextAreaValue (line 5) | function validateTextAreaValue(value: string): TResult {

FILE: frontend/email-builder/src/App/TemplatePanel/JsonPanel.tsx
  function JsonPanel (line 7) | function JsonPanel() {

FILE: frontend/email-builder/src/App/TemplatePanel/MainTabsGroup.tsx
  function MainTabsGroup (line 8) | function MainTabsGroup() {

FILE: frontend/email-builder/src/App/TemplatePanel/ShareButton.tsx
  function ShareButton (line 8) | function ShareButton() {

FILE: frontend/email-builder/src/App/TemplatePanel/helper/HighlightedCodePanel.tsx
  type TextEditorPanelProps (line 5) | type TextEditorPanelProps = {
  function HighlightedCodePanel (line 9) | function HighlightedCodePanel({ type, value }: TextEditorPanelProps) {

FILE: frontend/email-builder/src/App/TemplatePanel/helper/highlighters.tsx
  function html (line 12) | async function html(value: string): Promise<string> {
  function json (line 20) | async function json(value: string): Promise<string> {

FILE: frontend/email-builder/src/App/TemplatePanel/index.tsx
  function TemplatePanel (line 22) | function TemplatePanel() {

FILE: frontend/email-builder/src/App/index.tsx
  constant DEFAULT_SOURCE (line 10) | const DEFAULT_SOURCE: TEditorConfiguration = {
  function useDrawerTransition (line 17) | function useDrawerTransition(cssProperty: 'margin-left' | 'margin-right'...
  type AppProps (line 25) | interface AppProps {
  function App (line 34) | function App(props: AppProps) {

FILE: frontend/email-builder/src/documents/blocks/ColumnsContainer/ColumnsContainerEditor.tsx
  constant EMPTY_COLUMNS (line 11) | const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { child...
  function ColumnsContainerEditor (line 13) | function ColumnsContainerEditor({ style, props }: ColumnsContainerProps) {

FILE: frontend/email-builder/src/documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts
  type ColumnsContainerProps (line 22) | type ColumnsContainerProps = z.infer<typeof ColumnsContainerPropsSchema>;

FILE: frontend/email-builder/src/documents/blocks/Container/ContainerEditor.tsx
  function ContainerEditor (line 11) | function ContainerEditor({ style, props }: ContainerProps) {

FILE: frontend/email-builder/src/documents/blocks/Container/ContainerPropsSchema.tsx
  type ContainerProps (line 17) | type ContainerProps = z.infer<typeof ContainerPropsSchema>;

FILE: frontend/email-builder/src/documents/blocks/EmailLayout/EmailLayoutEditor.tsx
  function getFontFamily (line 9) | function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']) {
  function EmailLayoutEditor (line 33) | function EmailLayoutEditor(props: EmailLayoutProps) {

FILE: frontend/email-builder/src/documents/blocks/EmailLayout/EmailLayoutPropsSchema.tsx
  constant COLOR_SCHEMA (line 3) | const COLOR_SCHEMA = z
  constant FONT_FAMILY_SCHEMA (line 9) | const FONT_FAMILY_SCHEMA = z
  type EmailLayoutProps (line 36) | type EmailLayoutProps = z.infer<typeof EmailLayoutPropsSchema>;

FILE: frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlockButton.tsx
  type BlockMenuButtonProps (line 5) | type BlockMenuButtonProps = {
  constant BUTTON_SX (line 11) | const BUTTON_SX: SxProps = { p: 1.5, display: 'flex', flexDirection: 'co...
  constant ICON_SX (line 12) | const ICON_SX: SxProps = {
  function BlockTypeButton (line 23) | function BlockTypeButton({ label, icon, onClick }: BlockMenuButtonProps) {

FILE: frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlocksMenu.tsx
  type BlocksMenuProps (line 10) | type BlocksMenuProps = {
  function BlocksMenu (line 15) | function BlocksMenu({ anchorEl, setAnchorEl, onSelect }: BlocksMenuProps) {

FILE: frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/DividerButton.tsx
  type Props (line 6) | type Props = {
  function DividerButton (line 10) | function DividerButton({ buttonElement, onClick }: Props) {

FILE: frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/PlaceholderButton.tsx
  type Props (line 6) | type Props = {
  function PlaceholderButton (line 9) | function PlaceholderButton({ onClick }: Props) {

FILE: frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/buttons.tsx
  type TButtonProps (line 18) | type TButtonProps = {
  constant BUTTONS (line 23) | const BUTTONS: TButtonProps[] = [

FILE: frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/index.tsx
  type Props (line 9) | type Props = {
  function AddBlockButton (line 13) | function AddBlockButton({ onSelect, placeholder }: Props) {

FILE: frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/index.tsx
  type EditorChildrenChange (line 8) | type EditorChildrenChange = {
  function generateId (line 14) | function generateId() {
  type EditorChildrenIdsProps (line 18) | type EditorChildrenIdsProps = {
  function EditorChildrenIds (line 22) | function EditorChildrenIds({ childrenIds, onChange }: EditorChildrenIdsP...

FILE: frontend/email-builder/src/documents/blocks/helpers/TStyle.ts
  type TStyle (line 3) | type TStyle = {

FILE: frontend/email-builder/src/documents/blocks/helpers/block-wrappers/EditorBlockWrapper.tsx
  type TEditorBlockWrapperProps (line 10) | type TEditorBlockWrapperProps = {
  function EditorBlockWrapper (line 14) | function EditorBlockWrapper({ children }: TEditorBlockWrapperProps) {

FILE: frontend/email-builder/src/documents/blocks/helpers/block-wrappers/ReaderBlockWrapper.tsx
  type TReaderBlockWrapperProps (line 5) | type TReaderBlockWrapperProps = {
  function ReaderBlockWrapper (line 10) | function ReaderBlockWrapper({ style, children }: TReaderBlockWrapperProp...

FILE: frontend/email-builder/src/documents/blocks/helpers/block-wrappers/TuneMenu.tsx
  type Props (line 19) | type Props = {
  function TuneMenu (line 22) | function TuneMenu({ blockId }: Props) {

FILE: frontend/email-builder/src/documents/blocks/helpers/fontFamily.ts
  constant FONT_FAMILIES (line 1) | const FONT_FAMILIES = [
  constant FONT_FAMILY_NAMES (line 51) | const FONT_FAMILY_NAMES = [

FILE: frontend/email-builder/src/documents/blocks/helpers/zod.ts
  function zColor (line 5) | function zColor() {
  function zFontFamily (line 9) | function zFontFamily() {
  function zFontWeight (line 13) | function zFontWeight() {
  function zTextAlign (line 17) | function zTextAlign() {
  function zPadding (line 21) | function zPadding() {

FILE: frontend/email-builder/src/documents/editor/EditorBlock.tsx
  type EditorBlockProps (line 9) | type EditorBlockProps = {
  function EditorBlock (line 18) | function EditorBlock({ id }: EditorBlockProps) {

FILE: frontend/email-builder/src/documents/editor/EditorContext.tsx
  type TValue (line 8) | type TValue = {
  function useDocument (line 31) | function useDocument() {
  function subscribeDocument (line 35) | function subscribeDocument (listener: (selectedState: TEditorConfigurati...
  function useSelectedBlockId (line 39) | function useSelectedBlockId() {
  function useSelectedScreenSize (line 43) | function useSelectedScreenSize() {
  function useSelectedMainTab (line 47) | function useSelectedMainTab() {
  function setSelectedMainTab (line 51) | function setSelectedMainTab(selectedMainTab: TValue['selectedMainTab']) {
  function useSelectedSidebarTab (line 55) | function useSelectedSidebarTab() {
  function useInspectorDrawerOpen (line 59) | function useInspectorDrawerOpen() {
  function useSamplesDrawerOpen (line 63) | function useSamplesDrawerOpen() {
  function setSelectedBlockId (line 67) | function setSelectedBlockId(selectedBlockId: TValue['selectedBlockId']) {
  function setSidebarTab (line 80) | function setSidebarTab(selectedSidebarTab: TValue['selectedSidebarTab']) {
  function resetDocument (line 84) | function resetDocument(document: TValue['document']) {
  function setDocument (line 92) | function setDocument(document: TValue['document']) {
  function toggleInspectorDrawerOpen (line 102) | function toggleInspectorDrawerOpen() {
  function toggleSamplesDrawerOpen (line 107) | function toggleSamplesDrawerOpen() {
  function setSelectedScreenSize (line 112) | function setSelectedScreenSize(selectedScreenSize: TValue['selectedScree...

FILE: frontend/email-builder/src/documents/editor/core.tsx
  constant EDITOR_DICTIONARY (line 26) | const EDITOR_DICTIONARY = buildBlockConfigurationDictionary({
  type TEditorBlock (line 126) | type TEditorBlock = z.infer<typeof EditorBlockSchema>;
  type TEditorConfiguration (line 127) | type TEditorConfiguration = Record<string, TEditorBlock>;

FILE: frontend/email-builder/src/getConfiguration/index.tsx
  function getConfiguration (line 3) | function getConfiguration(template: string) {

FILE: frontend/email-builder/src/getConfiguration/sample/empty-email-message.ts
  constant EMPTY_EMAIL_MESSAGE (line 3) | const EMPTY_EMAIL_MESSAGE: TEditorConfiguration = {

FILE: frontend/email-builder/src/main.tsx
  function isRendered (line 9) | function isRendered(containerId: string): boolean {
  function render (line 18) | function render(containerId: string, props: AppProps, force: boolean = f...

FILE: frontend/email-builder/src/theme.ts
  constant BRAND_NAVY (line 5) | const BRAND_NAVY = '#212443';
  constant BRAND_BLUE (line 6) | const BRAND_BLUE = '#0079CC';
  constant BRAND_GREEN (line 7) | const BRAND_GREEN = '#1F8466';
  constant BRAND_RED (line 8) | const BRAND_RED = '#E81212';
  constant BRAND_YELLOW (line 9) | const BRAND_YELLOW = '#F6DC9F';
  constant BRAND_PURPLE (line 10) | const BRAND_PURPLE = '#6C0E7C';
  constant BRAND_BROWN (line 11) | const BRAND_BROWN = '#CC996C';
  constant STANDARD_FONT_FAMILY (line 12) | const STANDARD_FONT_FAMILY = 'sans-serif, "Segoe UI", Roboto, Helvetica,...
  constant MONOSPACE_FONT_FAMILY (line 13) | const MONOSPACE_FONT_FAMILY = 'monospace, Menlo, Monaco, "Segoe UI Mono"...
  constant BASE_THEME (line 15) | const BASE_THEME = createTheme({
  constant THEME (line 30) | const THEME = createTheme(BASE_THEME, {

FILE: frontend/email-builder/src/utils.tsx
  function renderHtmlWithMeta (line 4) | function renderHtmlWithMeta(document: TEditorConfiguration, options: { r...

FILE: frontend/src/main.js
  function initConfig (line 34) | async function initConfig(app) {
  method loadConfig (line 101) | loadConfig() {
  method awaitRestart (line 108) | awaitRestart(response) {
  method mounted (line 131) | mounted() {

FILE: frontend/src/router/index.js
  method scrollBehavior (line 147) | scrollBehavior(to) {

FILE: frontend/src/store/index.js
  method setModelResponse (line 25) | setModelResponse(state, { model, data }) {
  method setLoading (line 33) | setLoading(state, { model, status }) {

FILE: frontend/src/utils.js
  class Utils (line 28) | class Utils {
    method constructor (line 29) | constructor(i18n) {
    method formatNumber (line 121) | formatNumber(v) {

FILE: internal/auth/auth.go
  type OIDCclaim (line 23) | type OIDCclaim struct
  type OIDCConfig (line 32) | type OIDCConfig struct
  type BasicAuthConfig (line 43) | type BasicAuthConfig struct
  type Config (line 49) | type Config struct
  type Callbacks (line 55) | type Callbacks struct
  type Auth (line 61) | type Auth struct
    method CacheAPIUsers (line 118) | func (o *Auth) CacheAPIUsers(users []User) {
    method CacheAPIUser (line 129) | func (o *Auth) CacheAPIUser(u User) {
    method GetAPIToken (line 136) | func (o *Auth) GetAPIToken(user string, token string) (User, bool) {
    method initOIDC (line 149) | func (o *Auth) initOIDC() error {
    method getProvider (line 176) | func (o *Auth) getProvider() (*oidc.Provider, error) {
    method getVerifier (line 189) | func (o *Auth) getVerifier() (*oidc.IDTokenVerifier, error) {
    method getOAuthConfig (line 202) | func (o *Auth) getOAuthConfig() (*oauth2.Config, error) {
    method GetOIDCAuthURL (line 215) | func (o *Auth) GetOIDCAuthURL(state, nonce string) string {
    method ExchangeOIDCToken (line 226) | func (o *Auth) ExchangeOIDCToken(code, nonce string) (string, OIDCclai...
    method Middleware (line 286) | func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc {
    method Perm (line 336) | func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.Handl...
    method SaveSession (line 370) | func (o *Auth) SaveSession(u User, oidcToken string, c echo.Context) e...
    method validateSession (line 386) | func (o *Auth) validateSession(c echo.Context) (*simplesessions.Sessio...
  function New (line 78) | func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, ...
  function GetUser (line 417) | func GetUser(c echo.Context) User {
  function parseAuthHeader (line 422) | func parseAuthHeader(h string) (string, string, error) {

FILE: internal/auth/models.go
  type PermType (line 15) | type PermType
  constant PermTypeGet (line 18) | PermTypeGet PermType = 1 << iota
  constant PermTypeManage (line 19) | PermTypeManage
  constant UserHTTPCtxKey (line 24) | UserHTTPCtxKey = "auth_user"
  constant SessionKey (line 25) | SessionKey     = "auth_session"
  constant SuperAdminRoleID (line 30) | SuperAdminRoleID = 1
  constant UserTypeUser (line 33) | UserTypeUser       = "user"
  constant UserTypeAPI (line 34) | UserTypeAPI        = "api"
  constant UserStatusEnabled (line 35) | UserStatusEnabled  = "enabled"
  constant UserStatusDisabled (line 36) | UserStatusDisabled = "disabled"
  constant RoleTypeUser (line 39) | RoleTypeUser = "user"
  constant RoleTypeList (line 40) | RoleTypeList = "list"
  constant PermListGetAll (line 45) | PermListGetAll            = "lists:get_all"
  constant PermListManageAll (line 46) | PermListManageAll         = "lists:manage_all"
  constant PermListManage (line 47) | PermListManage            = "list:manage"
  constant PermListGet (line 48) | PermListGet               = "list:get"
  constant PermSubscribersGet (line 49) | PermSubscribersGet        = "subscribers:get"
  constant PermSubscribersGetAll (line 50) | PermSubscribersGetAll     = "subscribers:get_all"
  constant PermSubscribersManage (line 51) | PermSubscribersManage     = "subscribers:manage"
  constant PermSubscribersImport (line 52) | PermSubscribersImport     = "subscribers:import"
  constant PermSubscribersSqlQuery (line 53) | PermSubscribersSqlQuery   = "subscribers:sql_query"
  constant PermTxSend (line 54) | PermTxSend                = "tx:send"
  constant PermCampaignsGet (line 55) | PermCampaignsGet          = "campaigns:get"
  constant PermCampaignsGetAll (line 56) | PermCampaignsGetAll       = "campaigns:get_all"
  constant PermCampaignsGetAnalytics (line 57) | PermCampaignsGetAnalytics = "campaigns:get_analytics"
  constant PermCampaignsManage (line 58) | PermCampaignsManage       = "campaigns:manage"
  constant PermCampaignsManageAll (line 59) | PermCampaignsManageAll    = "campaigns:manage_all"
  constant PermBouncesGet (line 60) | PermBouncesGet            = "bounces:get"
  constant PermBouncesManage (line 61) | PermBouncesManage         = "bounces:manage"
  constant PermWebhooksPostBounce (line 62) | PermWebhooksPostBounce    = "webhooks:post_bounce"
  constant PermMediaGet (line 63) | PermMediaGet              = "media:get"
  constant PermMediaManage (line 64) | PermMediaManage           = "media:manage"
  constant PermTemplatesGet (line 65) | PermTemplatesGet          = "templates:get"
  constant PermTemplatesManage (line 66) | PermTemplatesManage       = "templates:manage"
  constant PermUsersGet (line 67) | PermUsersGet              = "users:get"
  constant PermUsersManage (line 68) | PermUsersManage           = "users:manage"
  constant PermRolesGet (line 69) | PermRolesGet              = "roles:get"
  constant PermRolesManage (line 70) | PermRolesManage           = "roles:manage"
  constant PermSettingsGet (line 71) | PermSettingsGet           = "settings:get"
  constant PermSettingsManage (line 72) | PermSettingsManage        = "settings:manage"
  constant PermSettingsMaintain (line 73) | PermSettingsMaintain      = "settings:maintain"
  type Base (line 77) | type Base struct
  type User (line 84) | type User struct
    method HasPerm (line 160) | func (u *User) HasPerm(perm string) bool {
    method HasListPerm (line 172) | func (u *User) HasListPerm(types PermType, listIDs ...int) error {
    method hasListPerm (line 203) | func (u *User) hasListPerm(perm string, listID int) bool {
    method GetPermittedLists (line 221) | func (u *User) GetPermittedLists(types PermType) (bool, []int) {
    method FilterListsByPerm (line 270) | func (u *User) FilterListsByPerm(types PermType, listIDs []int) []int {
  type ListPermission (line 123) | type ListPermission struct
  type ListRolePermissions (line 129) | type ListRolePermissions struct
  type Role (line 135) | type Role struct
  type ListRole (line 148) | type ListRole struct

FILE: internal/bounce/bounce.go
  type Mailbox (line 16) | type Mailbox interface
  type Opt (line 21) | type Opt struct
  type Manager (line 43) | type Manager struct
    method Run (line 109) | func (m *Manager) Run() {
    method runMailboxScanner (line 126) | func (m *Manager) runMailboxScanner() {
    method Record (line 138) | func (m *Manager) Record(b models.Bounce) error {
  type Queries (line 56) | type Queries struct
  function New (line 62) | func New(opt Opt, q *Queries, lo *log.Logger) (*Manager, error) {

FILE: internal/bounce/mailbox/opt.go
  type Opt (line 6) | type Opt struct

FILE: internal/bounce/mailbox/pop.go
  type POP (line 19) | type POP struct
    method Scan (line 79) | func (p *POP) Scan(limit int, ch chan models.Bounce) error {
  type bounceHeaders (line 25) | type bounceHeaders struct
  type bounceMeta (line 30) | type bounceMeta struct
  function NewPOP (line 63) | func NewPOP(opt Opt, lo *log.Logger) *POP {
  function classifyBounce (line 214) | func classifyBounce(b []byte) (string, string) {

FILE: internal/bounce/webhooks/forwardemail.go
  type BounceDetails (line 16) | type BounceDetails struct
  type forwardemailNotif (line 25) | type forwardemailNotif struct
  type Forwardemail (line 41) | type Forwardemail struct
    method ProcessBounce (line 49) | func (p *Forwardemail) ProcessBounce(sigHex string, body []byte) ([]mo...
  function NewForwardemail (line 45) | func NewForwardemail(key []byte) *Forwardemail {

FILE: internal/bounce/webhooks/postmark.go
  type postmarkNotif (line 15) | type postmarkNotif struct
  type Postmark (line 39) | type Postmark struct
    method ProcessBounce (line 52) | func (p *Postmark) ProcessBounce(b []byte, c echo.Context) ([]models.B...
  function NewPostmark (line 43) | func NewPostmark(username, password string) *Postmark {
  function makePostmarkAuthHandler (line 101) | func makePostmarkAuthHandler(cfgUser, cfgPassword string) func(username,...

FILE: internal/bounce/webhooks/sendgrid.go
  type sendgridNotif (line 19) | type sendgridNotif struct
  type Sendgrid (line 32) | type Sendgrid struct
    method ProcessBounce (line 53) | func (s *Sendgrid) ProcessBounce(sig, timestamp string, b []byte) ([]m...
    method verifyNotif (line 90) | func (s *Sendgrid) verifyNotif(sig, timestamp string, b []byte) error {
  function NewSendgrid (line 37) | func NewSendgrid(key string) (*Sendgrid, error) {

FILE: internal/bounce/webhooks/ses.go
  type sesNotif (line 28) | type sesNotif struct
  type sesTimestamp (line 46) | type sesTimestamp
    method UnmarshalJSON (line 260) | func (st *sesTimestamp) UnmarshalJSON(b []byte) error {
  type sesMail (line 48) | type sesMail struct
  type SES (line 67) | type SES struct
    method ProcessSubscription (line 80) | func (s *SES) ProcessSubscription(b []byte) error {
    method ProcessBounce (line 108) | func (s *SES) ProcessBounce(b []byte) (models.Bounce, error) {
    method buildSignature (line 175) | func (s *SES) buildSignature(n sesNotif) []byte {
    method verifyNotif (line 199) | func (s *SES) verifyNotif(n sesNotif) error {
    method getCert (line 216) | func (s *SES) getCert(certURL string) (*x509.Certificate, error) {
  function NewSES (line 72) | func NewSES() *SES {

FILE: internal/buflog/buflog.go
  type BufLog (line 11) | type BufLog struct
    method Write (line 30) | func (bu *BufLog) Write(b []byte) (n int, err error) {
    method Lines (line 44) | func (bu *BufLog) Lines() []string {
  function New (line 20) | func New(maxLines int) *BufLog {

FILE: internal/captcha/captcha.go
  constant hCaptchaURL (line 19) | hCaptchaURL = "https://hcaptcha.com/siteverify"
  type hCaptchaResp (line 22) | type hCaptchaResp struct
  constant ProviderNone (line 28) | ProviderNone     = ""
  constant ProviderHCaptcha (line 29) | ProviderHCaptcha = "hcaptcha"
  constant ProviderAltcha (line 30) | ProviderAltcha   = "altcha"
  type Captcha (line 34) | type Captcha struct
    method IsEnabled (line 105) | func (c *Captcha) IsEnabled() bool {
    method GetProvider (line 110) | func (c *Captcha) GetProvider() string {
    method GenerateChallenge (line 117) | func (c *Captcha) GenerateChallenge() (string, error) {
    method Verify (line 147) | func (c *Captcha) Verify(token string) (error, bool) {
    method verifyHCaptcha (line 159) | func (c *Captcha) verifyHCaptcha(token string) (error, bool) {
    method verifyAltcha (line 187) | func (c *Captcha) verifyAltcha(payload string) (error, bool) {
  type Opt (line 41) | type Opt struct
  type hCaptchaOpt (line 53) | type hCaptchaOpt struct
  type altchaOpt (line 57) | type altchaOpt struct
  function New (line 63) | func New(o Opt) *Captcha {

FILE: internal/core/bounces.go
  method QueryBounces (line 16) | func (c *Core) QueryBounces(campID, subID int, source, orderBy, order st...
  method GetBounce (line 41) | func (c *Core) GetBounce(id int) (models.Bounce, error) {
  method RecordBounce (line 60) | func (c *Core) RecordBounce(b models.Bounce) error {
  method BlocklistBouncedSubscribers (line 90) | func (c *Core) BlocklistBouncedSubscribers() error {
  method DeleteBounce (line 100) | func (c *Core) DeleteBounce(id int) error {
  method DeleteBounces (line 105) | func (c *Core) DeleteBounces(ids []int, all bool) error {

FILE: internal/core/campaigns.go
  constant CampaignAnalyticsViews (line 16) | CampaignAnalyticsViews   = "views"
  constant CampaignAnalyticsClicks (line 17) | CampaignAnalyticsClicks  = "clicks"
  constant CampaignAnalyticsBounces (line 18) | CampaignAnalyticsBounces = "bounces"
  constant campaignTplDefault (line 20) | campaignTplDefault = "default"
  constant campaignTplArchive (line 21) | campaignTplArchive = "archive"
  method QueryCampaigns (line 26) | func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string,...
  method GetCampaign (line 68) | func (c *Core) GetCampaign(id int, uuid, archiveSlug string) (models.Cam...
  method GetArchivedCampaign (line 73) | func (c *Core) GetArchivedCampaign(id int, uuid, archiveSlug string) (mo...
  method getCampaign (line 90) | func (c *Core) getCampaign(id int, uuid, archiveSlug string, tplType str...
  method GetCampaignForPreview (line 129) | func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, er...
  method GetArchivedCampaigns (line 146) | func (c *Core) GetArchivedCampaigns(offset, limit int) (models.Campaigns...
  method CreateCampaign (line 163) | func (c *Core) CreateCampaign(o models.Campaign, listIDs []int, mediaIDs...
  method UpdateCampaign (line 214) | func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, ...
  method UpdateCampaignStatus (line 250) | func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campa...
  method UpdateCampaignArchive (line 306) | func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, me...
  method DeleteCampaign (line 318) | func (c *Core) DeleteCampaign(id int) error {
  method DeleteCampaigns (line 336) | func (c *Core) DeleteCampaigns(ids []int, query string, hasAllPerm bool,...
  method CampaignHasLists (line 355) | func (c *Core) CampaignHasLists(id int, listIDs []int) (bool, error) {
  method GetRunningCampaignStats (line 367) | func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) {
  method GetCampaignAnalyticsCounts (line 384) | func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, ...
  method GetCampaignAnalyticsLinks (line 413) | func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, t...
  method RegisterCampaignView (line 425) | func (c *Core) RegisterCampaignView(campUUID, subUUID string) error {
  method GetLinkURL (line 439) | func (c *Core) GetLinkURL(linkUUID string) (string, error) {
  method RegisterCampaignLinkClick (line 449) | func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID str...
  method DeleteCampaignViews (line 464) | func (c *Core) DeleteCampaignViews(before time.Time) error {
  method DeleteCampaignLinkClicks (line 474) | func (c *Core) DeleteCampaignLinkClicks(before time.Time) error {

FILE: internal/core/core.go
  constant SortAsc (line 23) | SortAsc  = "asc"
  constant SortDesc (line 24) | SortDesc = "desc"
  constant matDashboardCharts (line 26) | matDashboardCharts = "mat_dashboard_charts"
  constant matDashboardCounts (line 27) | matDashboardCounts = "mat_dashboard_counts"
  constant matListSubStats (line 28) | matListSubStats    = "mat_list_subscriber_stats"
  type Core (line 32) | type Core struct
    method RefreshMatViews (line 91) | func (c *Core) RefreshMatViews(concurrent bool) error {
    method RefreshMatView (line 99) | func (c *Core) RefreshMatView(name string, concurrent bool) error {
    method refreshCache (line 116) | func (c *Core) refreshCache(name string, concurrent bool) error {
  type Constants (line 43) | type Constants struct
  type Hooks (line 53) | type Hooks struct
  type Opt (line 58) | type Opt struct
  function New (line 79) | func New(o *Opt, h *Hooks) *Core {
  function pqErrMsg (line 126) | func pqErrMsg(err error) string {
  function makeSearchQuery (line 138) | func makeSearchQuery(searchStr, orderBy, order, query string, querySortF...
  function makeSearchString (line 155) | func makeSearchString(searchStr string) string {
  function strSliceContains (line 163) | func strSliceContains(str string, sl []string) bool {
  function normalizeTags (line 175) | func normalizeTags(tags []string) []string {
  function sanitizeSQLExp (line 193) | func sanitizeSQLExp(q string) string {
  function strHasLen (line 207) | func strHasLen(str string, min, max int) bool {

FILE: internal/core/dashboard.go
  method GetDashboardCharts (line 11) | func (c *Core) GetDashboardCharts() (types.JSONText, error) {
  method GetDashboardCounts (line 24) | func (c *Core) GetDashboardCounts() (types.JSONText, error) {

FILE: internal/core/lists.go
  type listType (line 12) | type listType struct
  method GetLists (line 19) | func (c *Core) GetLists(typ, status string, getAll bool, permittedIDs []...
  method QueryLists (line 45) | func (c *Core) QueryLists(searchStr, typ, optin, status string, tags []s...
  method GetList (line 78) | func (c *Core) GetList(id int, uuid string) (models.List, error) {
  method GetListsByOptin (line 110) | func (c *Core) GetListsByOptin(ids []int, optinType string) ([]models.Li...
  method GetListTypes (line 126) | func (c *Core) GetListTypes(ids []int, uuids []string) (map[any]string, ...
  method CreateList (line 149) | func (c *Core) CreateList(l models.List) (models.List, error) {
  method UpdateList (line 180) | func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
  method DeleteList (line 197) | func (c *Core) DeleteList(id int) error {
  method DeleteLists (line 202) | func (c *Core) DeleteLists(ids []int, query string, getAll bool, permitt...

FILE: internal/core/media.go
  method QueryMedia (line 17) | func (c *Core) QueryMedia(provider string, s media.Store, query string, ...
  method GetMedia (line 47) | func (c *Core) GetMedia(id int, uuid, fileName string, s media.Store) (m...
  method InsertMedia (line 73) | func (c *Core) InsertMedia(fileName, thumbName, contentType string, meta...
  method DeleteMedia (line 93) | func (c *Core) DeleteMedia(id int) (string, error) {

FILE: internal/core/roles.go
  method GetRoles (line 13) | func (c *Core) GetRoles() ([]auth.Role, error) {
  method GetRole (line 24) | func (c *Core) GetRole(id int) (auth.Role, error) {
  method GetListRoles (line 41) | func (c *Core) GetListRoles() ([]auth.ListRole, error) {
  method CreateRole (line 63) | func (c *Core) CreateRole(r auth.Role) (auth.Role, error) {
  method CreateListRole (line 75) | func (c *Core) CreateListRole(r auth.ListRole) (auth.ListRole, error) {
  method UpsertListPermissions (line 92) | func (c *Core) UpsertListPermissions(roleID int, lp []auth.ListPermissio...
  method DeleteListPermission (line 120) | func (c *Core) DeleteListPermission(roleID, listID int) error {
  method UpdateUserRole (line 133) | func (c *Core) UpdateUserRole(id int, r auth.Role) (auth.Role, error) {
  method UpdateListRole (line 149) | func (c *Core) UpdateListRole(id int, r auth.ListRole) (auth.ListRole, e...
  method DeleteRole (line 170) | func (c *Core) DeleteRole(id int) error {

FILE: internal/core/settings.go
  method GetSettings (line 13) | func (c *Core) GetSettings() (models.Settings, error) {
  method UpdateSettings (line 35) | func (c *Core) UpdateSettings(s models.Settings) error {
  method UpdateSettingsByKey (line 53) | func (c *Core) UpdateSettingsByKey(key string, value json.RawMessage) er...

FILE: internal/core/subscribers.go
  method GetSubscriber (line 34) | func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscri...
  method HasSubscriberLists (line 63) | func (c *Core) HasSubscriberLists(subIDs []int, listIDs []int) (map[int]...
  method GetSubscribersByEmail (line 84) | func (c *Core) GetSubscribersByEmail(emails []string) (models.Subscriber...
  method QuerySubscribers (line 106) | func (c *Core) QuerySubscribers(searchStr, queryExp string, listIDs []in...
  method GetSubscriberLists (line 174) | func (c *Core) GetSubscriberLists(subID int, uuid string, listIDs []int,...
  method GetSubscriberProfileForExport (line 201) | func (c *Core) GetSubscriberProfileForExport(id int, uuid string) (model...
  method GetSubscriberActivity (line 219) | func (c *Core) GetSubscriberActivity(id int) (models.SubscriberActivity,...
  method ExportSubscribers (line 235) | func (c *Core) ExportSubscribers(searchStr, query string, subIDs, listID...
  method InsertSubscriber (line 286) | func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, li...
  method UpdateSubscriber (line 352) | func (c *Core) UpdateSubscriber(id int, sub models.Subscriber) (models.S...
  method UpdateSubscriberWithLists (line 388) | func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, ...
  method BlocklistSubscribers (line 441) | func (c *Core) BlocklistSubscribers(subIDs []int) error {
  method BlocklistSubscribersByQuery (line 452) | func (c *Core) BlocklistSubscribersByQuery(searchStr, queryExp string, l...
  method DeleteSubscribers (line 463) | func (c *Core) DeleteSubscribers(subIDs []int, subUUIDs []string) error {
  method DeleteSubscribersByQuery (line 481) | func (c *Core) DeleteSubscribersByQuery(searchStr, queryExp string, list...
  method UnsubscribeByCampaign (line 493) | func (c *Core) UnsubscribeByCampaign(subUUID, campUUID string, blocklist...
  method ConfirmOptionSubscription (line 504) | func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []str...
  method DeleteSubscriberBounces (line 519) | func (c *Core) DeleteSubscriberBounces(id int, uuid string) error {
  method DeleteOrphanSubscribers (line 535) | func (c *Core) DeleteOrphanSubscribers() (int, error) {
  method DeleteBlocklistedSubscribers (line 548) | func (c *Core) DeleteBlocklistedSubscribers() (int, error) {
  method getSubscriberCount (line 560) | func (c *Core) getSubscriberCount(searchStr, queryExp, subStatus string,...
  function validateQueryTables (line 595) | func validateQueryTables(db *sqlx.DB, query string, allowedTables map[st...
  function getTablesFromQueryPlan (line 625) | func getTablesFromQueryPlan(explainJSON string) ([]string, error) {
  function traverseQueryPlan (line 644) | func traverseQueryPlan(node map[string]any, tables map[string]struct{}) {

FILE: internal/core/subscriptions.go
  method GetSubscriptions (line 13) | func (c *Core) GetSubscriptions(subID int, subUUID string, allLists bool...
  method AddSubscriptions (line 26) | func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) er...
  method AddSubscriptionsByQuery (line 38) | func (c *Core) AddSubscriptionsByQuery(searchStr, queryExp string, sourc...
  method DeleteSubscriptions (line 54) | func (c *Core) DeleteSubscriptions(subIDs, listIDs []int) error {
  method DeleteSubscriptionsByQuery (line 67) | func (c *Core) DeleteSubscriptionsByQuery(searchStr, queryExp string, so...
  method UnsubscribeLists (line 83) | func (c *Core) UnsubscribeLists(subIDs, listIDs []int, listUUIDs []strin...
  method UnsubscribeListsByQuery (line 95) | func (c *Core) UnsubscribeListsByQuery(searchStr, queryExp string, sourc...
  method DeleteUnconfirmedSubscriptions (line 112) | func (c *Core) DeleteUnconfirmedSubscriptions(beforeDate time.Time) (int...

FILE: internal/core/templates.go
  method GetTemplates (line 13) | func (c *Core) GetTemplates(status string, noBody bool) ([]models.Templa...
  method GetTemplate (line 24) | func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
  method CreateTemplate (line 40) | func (c *Core) CreateTemplate(name, typ, subject string, body []byte, bo...
  method UpdateTemplate (line 51) | func (c *Core) UpdateTemplate(id int, name, subject string, body []byte,...
  method SetDefaultTemplate (line 67) | func (c *Core) SetDefaultTemplate(id int) error {
  method DeleteTemplate (line 77) | func (c *Core) DeleteTemplate(id int) error {

FILE: internal/core/users.go
  method GetUsers (line 15) | func (c *Core) GetUsers() ([]auth.User, error) {
  method GetUser (line 26) | func (c *Core) GetUser(id int, username, email string) (auth.User, error) {
  method CreateUser (line 43) | func (c *Core) CreateUser(u auth.User) (auth.User, error) {
  method UpdateUser (line 76) | func (c *Core) UpdateUser(id int, u auth.User) (auth.User, error) {
  method UpdateUserProfile (line 100) | func (c *Core) UpdateUserProfile(id int, u auth.User) (auth.User, error) {
  method UpdateUserLogin (line 116) | func (c *Core) UpdateUserLogin(id int, avatar string) error {
  method SetTwoFA (line 126) | func (c *Core) SetTwoFA(id int, twofaType, twofaKey string) error {
  method DeleteUsers (line 136) | func (c *Core) DeleteUsers(ids []int) error {
  method LoginUser (line 150) | func (c *Core) LoginUser(username, password string) (auth.User, error) {
  method setupUserFields (line 165) | func (c *Core) setupUserFields(users []auth.User) []auth.User {

FILE: internal/events/events.go
  constant TypeError (line 14) | TypeError = "error"
  type Event (line 18) | type Event struct
  type Events (line 26) | type Events struct
    method Subscribe (line 41) | func (ev *Events) Subscribe(id string) (chan Event, error) {
    method Unsubscribe (line 56) | func (ev *Events) Unsubscribe(id string) {
    method Publish (line 63) | func (ev *Events) Publish(e Event) error {
    method ErrWriter (line 98) | func (ev *Events) ErrWriter() io.Writer {
  function New (line 32) | func New() *Events {
  type wri (line 80) | type wri struct
    method Write (line 84) | func (w *wri) Write(b []byte) (n int, err error) {

FILE: internal/i18n/i18n.go
  type I18n (line 14) | type I18n struct
    method Load (line 48) | func (i *I18n) Load(b []byte) error {
    method JSON (line 62) | func (i *I18n) JSON() []byte {
    method T (line 68) | func (i *I18n) T(key string) string {
    method Ts (line 86) | func (i *I18n) Ts(key string, params ...string) string {
    method Tc (line 109) | func (i *I18n) Tc(key string, n int) string {
    method getSingular (line 125) | func (i *I18n) getSingular(s string) string {
    method getPlural (line 135) | func (i *I18n) getPlural(s string) string {
    method subAllParams (line 149) | func (i *I18n) subAllParams(s string) string {
  function New (line 23) | func New(b []byte) (*I18n, error) {

FILE: internal/manager/manager.go
  constant BaseTPL (line 25) | BaseTPL = "base"
  constant ContentTpl (line 28) | ContentTpl = "content"
  constant dummyUUID (line 30) | dummyUUID = "00000000-0000-0000-0000-000000000000"
  type Store (line 35) | type Store interface
  type Messenger (line 49) | type Messenger interface
  type CampStats (line 57) | type CampStats struct
  type Manager (line 63) | type Manager struct
    method AddMessenger (line 184) | func (m *Manager) AddMessenger(msg Messenger) error {
    method PushMessage (line 196) | func (m *Manager) PushMessage(msg models.Message) error {
    method PushCampaignMessage (line 212) | func (m *Manager) PushCampaignMessage(msg CampaignMessage) error {
    method HasMessenger (line 232) | func (m *Manager) HasMessenger(id string) bool {
    method HasRunningCampaigns (line 239) | func (m *Manager) HasRunningCampaigns() bool {
    method GetCampaignStats (line 247) | func (m *Manager) GetCampaignStats(id int) CampStats {
    method Run (line 265) | func (m *Manager) Run() {
    method CacheTpl (line 318) | func (m *Manager) CacheTpl(id int, tpl *models.Template) {
    method DeleteTpl (line 325) | func (m *Manager) DeleteTpl(id int) {
    method GetTpl (line 332) | func (m *Manager) GetTpl(id int) (*models.Template, error) {
    method TemplateFuncs (line 346) | func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
    method GenericTemplateFuncs (line 400) | func (m *Manager) GenericTemplateFuncs() template.FuncMap {
    method StopCampaign (line 405) | func (m *Manager) StopCampaign(id int) {
    method Close (line 414) | func (m *Manager) Close() {
    method scanCampaigns (line 422) | func (m *Manager) scanCampaigns(tick time.Duration) {
    method worker (line 462) | func (m *Manager) worker() {
    method getCurrentCampaigns (line 563) | func (m *Manager) getCurrentCampaigns() ([]int64, []int64) {
    method trackLink (line 586) | func (m *Manager) trackLink(url, campUUID, subUUID string) string {
    method sendNotif (line 617) | func (m *Manager) sendNotif(c *models.Campaign, status, reason string)...
    method makeGnericFuncMap (line 635) | func (m *Manager) makeGnericFuncMap() template.FuncMap {
    method attachMedia (line 664) | func (m *Manager) attachMedia(c *models.Campaign) error {
  type CampaignMessage (line 99) | type CampaignMessage struct
  type Config (line 114) | type Config struct
  function New (line 150) | func New(cfg Config, store Store, i *i18n.I18n, l *log.Logger) *Manager {
  function MakeAttachmentHeader (line 685) | func MakeAttachmentHeader(filename, encoding, contentType string) textpr...

FILE: internal/manager/message.go
  method NewCampaignMessage (line 13) | func (m *Manager) NewCampaignMessage(c *models.Campaign, s models.Subscr...
  method render (line 33) | func (m *CampaignMessage) render() error {
  method Subject (line 68) | func (m *CampaignMessage) Subject() string {
  method Body (line 73) | func (m *CampaignMessage) Body() []byte {
  method AltBody (line 80) | func (m *CampaignMessage) AltBody() []byte {

FILE: internal/manager/pipe.go
  type pipe (line 13) | type pipe struct
    method NextSubscribers (line 76) | func (p *pipe) NextSubscribers() (bool, error) {
    method OnError (line 138) | func (p *pipe) OnError() {
    method Stop (line 156) | func (p *pipe) Stop(withErrors bool) {
    method newMessage (line 172) | func (p *pipe) newMessage(s models.Subscriber) (CampaignMessage, error) {
    method cleanup (line 187) | func (p *pipe) cleanup() {
  method newPipe (line 27) | func (m *Manager) newPipe(c *models.Campaign) (*pipe, error) {

FILE: internal/media/media.go
  type Media (line 11) | type Media struct
  type Store (line 27) | type Store interface

FILE: internal/media/providers/filesystem/filesystem.go
  type Opts (line 13) | type Opts struct
  type Client (line 20) | type Client struct
    method Put (line 32) | func (c *Client) Put(filename string, cType string, src io.ReadSeeker)...
    method GetURL (line 52) | func (c *Client) GetURL(name string) string {
    method GetBlob (line 57) | func (c *Client) GetBlob(url string) ([]byte, error) {
    method Delete (line 63) | func (c *Client) Delete(file string) error {
  function New (line 25) | func New(opts Opts) (media.Store, error) {
  function getDir (line 72) | func getDir(dir string) string {

FILE: internal/media/providers/s3/s3.go
  type Opt (line 16) | type Opt struct
  type Client (line 30) | type Client struct
    method Put (line 67) | func (c *Client) Put(name string, cType string, file io.ReadSeeker) (s...
    method GetURL (line 91) | func (c *Client) GetURL(name string) string {
    method GetBlob (line 112) | func (c *Client) GetBlob(uurl string) ([]byte, error) {
    method Delete (line 139) | func (c *Client) Delete(name string) error {
    method makeBucketPath (line 150) | func (c *Client) makeBucketPath(name string) string {
    method makeFileURL (line 161) | func (c *Client) makeFileURL(name string) string {
  function NewS3Store (line 37) | func NewS3Store(opt Opt) (media.Store, error) {

FILE: internal/messenger/email/email.go
  constant MessengerName (line 16) | MessengerName = "email"
  constant hdrReturnPath (line 18) | hdrReturnPath = "Return-Path"
  constant hdrBcc (line 19) | hdrBcc        = "Bcc"
  constant hdrCc (line 20) | hdrCc         = "Cc"
  type Server (line 24) | type Server struct
  type Emailer (line 43) | type Emailer struct
    method Name (line 106) | func (e *Emailer) Name() string {
    method Push (line 111) | func (e *Emailer) Push(m models.Message) error {
    method Flush (line 196) | func (e *Emailer) Flush() error {
    method Close (line 201) | func (e *Emailer) Close() error {
  function New (line 51) | func New(name string, servers ...Server) (*Emailer, error) {

FILE: internal/messenger/postback/postback.go
  type postback (line 18) | type postback struct
  type campaign (line 28) | type campaign struct
  type recipient (line 36) | type recipient struct
  type attachment (line 44) | type attachment struct
  type Options (line 51) | type Options struct
  type Postback (line 62) | type Postback struct
    method Name (line 92) | func (p *Postback) Name() string {
    method Push (line 97) | func (p *Postback) Push(m models.Message) error {
    method Flush (line 145) | func (p *Postback) Flush() error {
    method Close (line 150) | func (p *Postback) Close() error {
    method exec (line 156) | func (p *Postback) exec(method, rURL string, reqBody []byte, headers h...
  function New (line 69) | func New(o Options) (*Postback, error) {

FILE: internal/messenger/postback/postback_easyjson.go
  function easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback (line 22) | func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostba...
  function easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback (line 115) | func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostba...
  method MarshalJSON (line 184) | func (v postback) MarshalJSON() ([]byte, error) {
  method MarshalEasyJSON (line 191) | func (v postback) MarshalEasyJSON(w *jwriter.Writer) {
  method UnmarshalJSON (line 196) | func (v *postback) UnmarshalJSON(data []byte) error {
  method UnmarshalEasyJSON (line 203) | func (v *postback) UnmarshalEasyJSON(l *jlexer.Lexer) {
  function easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback3 (line 206) | func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostba...
  function easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback3 (line 281) | func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostba...
  function easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback2 (line 329) | func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostba...
  function easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback2 (line 424) | func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostba...
  function easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback1 (line 493) | func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostba...
  function easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback1 (line 552) | func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostba...

FILE: internal/migrations/v0.4.0.go
  function V0_4_0 (line 12) | func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v0.7.0.go
  function V0_7_0 (line 12) | func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v0.8.0.go
  function V0_8_0 (line 12) | func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v0.9.0.go
  function V0_9_0 (line 13) | func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v1.0.0.go
  function V1_0_0 (line 12) | func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v2.0.0.go
  function V2_0_0 (line 12) | func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v2.1.0.go
  function V2_1_0 (line 12) | func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v2.2.0.go
  function V2_2_0 (line 12) | func V2_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v2.3.0.go
  function V2_3_0 (line 12) | func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v2.4.0.go
  function V2_4_0 (line 12) | func V2_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v2.5.0.go
  function V2_5_0 (line 12) | func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v3.0.0.go
  function V3_0_0 (line 12) | func V3_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v4.0.0.go
  function V4_0_0 (line 15) | func V4_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v4.1.0.go
  function V4_1_0 (line 12) | func V4_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v5.0.0.go
  function V5_0_0 (line 12) | func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v5.1.0.go
  function V5_1_0 (line 11) | func V5_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v6.0.0.go
  function V6_0_0 (line 11) | func V6_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/migrations/v6.1.0.go
  function V6_1_0 (line 11) | func V6_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *lo...

FILE: internal/notifs/notifs.go
  constant TplImport (line 20) | TplImport          = "import-status"
  constant TplCampaignStatus (line 21) | TplCampaignStatus  = "campaign-status"
  constant TplSubscriberOptin (line 22) | TplSubscriberOptin = "subscriber-optin"
  constant TplSubscriberData (line 23) | TplSubscriberData  = "subscriber-data"
  constant TplForgotPassword (line 24) | TplForgotPassword  = "forgot-password"
  type FuncPush (line 27) | type FuncPush
  type FuncNotif (line 28) | type FuncNotif
  type FuncNotifSystem (line 29) | type FuncNotifSystem
  type Opt (line 31) | type Opt struct
  type Notifs (line 37) | type Notifs struct
  function Initialize (line 52) | func Initialize(opt Opt, tpls *template.Template, em *email.Emailer, lo ...
  function NotifySystem (line 66) | func NotifySystem(subject, tplName string, data any, hdr textproto.MIMEH...
  function Notify (line 71) | func Notify(toEmails []string, subject, tplName string, data any, hdr te...
  function GetTplSubject (line 106) | func GetTplSubject(subject string, body []byte) (string, []byte) {

FILE: internal/subimporter/importer.go
  constant commitBatchSize (line 35) | commitBatchSize = 10000
  constant StatusNone (line 40) | StatusNone      = "none"
  constant StatusImporting (line 41) | StatusImporting = "importing"
  constant StatusStopping (line 42) | StatusStopping  = "stopping"
  constant StatusFinished (line 43) | StatusFinished  = "finished"
  constant StatusFailed (line 44) | StatusFailed    = "failed"
  constant ModeSubscribe (line 46) | ModeSubscribe = "subscribe"
  constant ModeBlocklist (line 47) | ModeBlocklist = "blocklist"
  type Importer (line 51) | type Importer struct
    method NewSession (line 167) | func (im *Importer) NewSession(opt SessionOpt) (*Session, error) {
    method GetStats (line 197) | func (im *Importer) GetStats() Status {
    method GetLogs (line 210) | func (im *Importer) GetLogs() []byte {
    method setStatus (line 222) | func (im *Importer) setStatus(status string) {
    method getStatus (line 229) | func (im *Importer) getStatus() string {
    method isDone (line 237) | func (im *Importer) isDone() bool {
    method incrementImportCount (line 249) | func (im *Importer) incrementImportCount(n int) {
    method sendNotif (line 256) | func (im *Importer) sendNotif(status string) error {
    method Stop (line 588) | func (im *Importer) Stop() {
    method SanitizeEmail (line 606) | func (im *Importer) SanitizeEmail(email string) (string, error) {
    method ValidateFields (line 643) | func (im *Importer) ValidateFields(s SubReq) (SubReq, error) {
    method checkInList (line 671) | func (im *Importer) checkInList(domain string, hasWildcards bool, mp m...
  type Options (line 70) | type Options struct
  type Session (line 81) | type Session struct
    method Start (line 273) | func (s *Session) Start() {
    method Stop (line 366) | func (s *Session) Stop() {
    method ExtractZIP (line 373) | func (s *Session) ExtractZIP(srcPath string, maxCSVs int) (string, []s...
    method LoadCSV (line 452) | func (s *Session) LoadCSV(srcPath string, delim rune) error {
    method mapCSVHeaders (line 697) | func (s *Session) mapCSVHeaders(csvHdrs []string, knownHdrs map[string...
  type SessionOpt (line 90) | type SessionOpt struct
  type Status (line 102) | type Status struct
  type SubReq (line 111) | type SubReq struct
  type importStatusTpl (line 118) | type importStatusTpl struct
  function New (line 139) | func New(opt Options, db *sql.DB, i *i18n.I18n) *Importer {
  function countLines (line 717) | func countLines(r io.Reader) (int, error) {
  function makeDomainMap (line 747) | func makeDomainMap(domains []string) (map[string]struct{}, bool) {

FILE: internal/tmptokens/tmptokens.go
  constant maxTries (line 15) | maxTries = 15
  type Token (line 19) | type Token struct
  function init (line 33) | func init() {
  function Set (line 46) | func Set(id string, ttl time.Duration, data any) {
  function Check (line 62) | func Check(id string) (any, error) {
  function Get (line 94) | func Get(id string) (any, error) {
  function Delete (line 116) | func Delete(id string) {
  function Clean (line 125) | func Clean() {

FILE: internal/utils/utils.go
  function ValidateEmail (line 12) | func ValidateEmail(email string) bool {
  function GenerateRandomString (line 25) | func GenerateRandomString(n int) (string, error) {
  function SanitizeURI (line 41) | func SanitizeURI(u string) string {

FILE: models/bounces.go
  constant BounceTypeHard (line 9) | BounceTypeHard      = "hard"
  constant BounceTypeSoft (line 10) | BounceTypeSoft      = "soft"
  constant BounceTypeComplaint (line 11) | BounceTypeComplaint = "complaint"
  type Bounce (line 15) | type Bounce struct

FILE: models/campaigns.go
  constant CampaignStatusDraft (line 19) | CampaignStatusDraft         = "draft"
  constant CampaignStatusScheduled (line 20) | CampaignStatusScheduled     = "scheduled"
  constant CampaignStatusRunning (line 21) | CampaignStatusRunning       = "running"
  constant CampaignStatusPaused (line 22) | CampaignStatusPaused        = "paused"
  constant CampaignStatusFinished (line 23) | CampaignStatusFinished      = "finished"
  constant CampaignStatusCancelled (line 24) | CampaignStatusCancelled     = "cancelled"
  constant CampaignTypeRegular (line 25) | CampaignTypeRegular         = "regular"
  constant CampaignTypeOptin (line 26) | CampaignTypeOptin           = "optin"
  constant CampaignContentTypeRichtext (line 27) | CampaignContentTypeRichtext = "richtext"
  constant CampaignContentTypeHTML (line 28) | CampaignContentTypeHTML     = "html"
  constant CampaignContentTypeMarkdown (line 29) | CampaignContentTypeMarkdown = "markdown"
  constant CampaignContentTypePlain (line 30) | CampaignContentTypePlain    = "plain"
  constant CampaignContentTypeVisual (line 31) | CampaignContentTypeVisual   = "visual"
  type Campaigns (line 35) | type Campaigns
    method GetIDs (line 103) | func (camps Campaigns) GetIDs() []int {
    method LoadStats (line 113) | func (camps Campaigns) LoadStats(stmt *sqlx.Stmt) error {
  type Campaign (line 38) | type Campaign struct
    method CompileTemplate (line 138) | func (c *Campaign) CompileTemplate(f template.FuncMap) error {
    method ConvertContent (line 214) | func (c *Campaign) ConvertContent(from, to string) (string, error) {
  type CampaignMeta (line 83) | type CampaignMeta struct

FILE: models/common.go
  constant EmailHeaderSubscriberUUID (line 19) | EmailHeaderSubscriberUUID = "X-Listmonk-Subscriber"
  constant EmailHeaderCampaignUUID (line 20) | EmailHeaderCampaignUUID   = "X-Listmonk-Campaign"
  constant EmailHeaderDate (line 23) | EmailHeaderDate        = "Date"
  constant EmailHeaderFrom (line 24) | EmailHeaderFrom        = "From"
  constant EmailHeaderSubject (line 25) | EmailHeaderSubject     = "Subject"
  constant EmailHeaderMessageId (line 26) | EmailHeaderMessageId   = "Message-Id"
  constant EmailHeaderDeliveredTo (line 27) | EmailHeaderDeliveredTo = "Delivered-To"
  constant EmailHeaderReceived (line 28) | EmailHeaderReceived    = "Received"
  constant TwofaTypeNone (line 31) | TwofaTypeNone = "none"
  constant TwofaTypeTOTP (line 32) | TwofaTypeTOTP = "totp"
  type regTplFunc (line 38) | type regTplFunc struct
  type Headers (line 92) | type Headers
    method Scan (line 150) | func (h *Headers) Scan(src any) error {
    method Value (line 169) | func (h Headers) Value() (driver.Value, error) {
  type PageResults (line 95) | type PageResults struct
  type Base (line 106) | type Base struct
  type JSON (line 113) | type JSON
    method Value (line 119) | func (s JSON) Value() (driver.Value, error) {
    method Scan (line 124) | func (s JSON) Scan(b any) error {
  type StringIntMap (line 116) | type StringIntMap
    method Scan (line 137) | func (s StringIntMap) Scan(src any) error {

FILE: models/lists.go
  constant ListTypePrivate (line 9) | ListTypePrivate    = "private"
  constant ListTypePublic (line 10) | ListTypePublic     = "public"
  constant ListOptinSingle (line 11) | ListOptinSingle    = "single"
  constant ListOptinDouble (line 12) | ListOptinDouble    = "double"
  constant ListStatusActive (line 13) | ListStatusActive   = "active"
  constant ListStatusArchived (line 14) | ListStatusArchived = "archived"
  type List (line 18) | type List struct

FILE: models/messages.go
  type Message (line 13) | type Message struct
  type Attachment (line 34) | type Attachment struct
  constant TxSubModeDefault (line 42) | TxSubModeDefault  = "default"
  constant TxSubModeFallback (line 43) | TxSubModeFallback = "fallback"
  constant TxSubModeExternal (line 44) | TxSubModeExternal = "external"
  type TxMessage (line 48) | type TxMessage struct
    method Render (line 74) | func (m *TxMessage) Render(sub Subscriber, tpl *Template, funcs txttpl...

FILE: models/queries.go
  type Queries (line 13) | type Queries struct
    method compileSubscriberQueryTpl (line 145) | func (q *Queries) compileSubscriberQueryTpl(searchStr, queryExp string...
    method ExecSubQueryTpl (line 170) | func (q *Queries) ExecSubQueryTpl(searchStr, queryExp, baseQueryTpl st...

FILE: models/settings.go
  type Settings (line 6) | type Settings struct

FILE: models/subscribers.go
  constant SubscriberStatusEnabled (line 15) | SubscriberStatusEnabled     = "enabled"
  constant SubscriberStatusDisabled (line 16) | SubscriberStatusDisabled    = "disabled"
  constant SubscriberStatusBlockListed (line 17) | SubscriberStatusBlockListed = "blocklisted"
  constant SubscriptionStatusUnconfirmed (line 19) | SubscriptionStatusUnconfirmed  = "unconfirmed"
  constant SubscriptionStatusConfirmed (line 20) | SubscriptionStatusConfirmed    = "confirmed"
  constant SubscriptionStatusUnsubscribed (line 21) | SubscriptionStatusUnsubscribed = "unsubscribed"
  type Subscribers (line 25) | type Subscribers
    method GetIDs (line 45) | func (subs Subscribers) GetIDs() []int {
    method LoadLists (line 56) | func (subs Subscribers) LoadLists(stmt *sqlx.Stmt) error {
  type Subscriber (line 28) | type Subscriber struct
    method FirstName (line 79) | func (s Subscriber) FirstName() string {
    method LastName (line 92) | func (s Subscriber) LastName() string {
  type subLists (line 39) | type subLists struct
  type Subscription (line 105) | type Subscription struct
  type SubscriberExport (line 113) | type SubscriberExport struct
  type SubscriberExportProfile (line 124) | type SubscriberExportProfile struct
  type SubscriberActivity (line 133) | type SubscriberActivity struct

FILE: models/templates.go
  constant BaseTpl (line 14) | BaseTpl                    = "base"
  constant ContentTpl (line 15) | ContentTpl                 = "content"
  constant TemplateTypeCampaign (line 16) | TemplateTypeCampaign       = "campaign"
  constant TemplateTypeCampaignVisual (line 17) | TemplateTypeCampaignVisual = "campaign_visual"
  constant TemplateTypeTx (line 18) | TemplateTypeTx             = "tx"
  type Template (line 22) | type Template struct
    method Compile (line 40) | func (t *Template) Compile(f template.FuncMap) error {
  type CampaignStats (line 61) | type CampaignStats struct
  type CampaignAnalyticsCount (line 72) | type CampaignAnalyticsCount struct
  type CampaignAnalyticsLink (line 78) | type CampaignAnalyticsLink struct

FILE: schema.sql
  type subscribers (line 20) | CREATE TABLE subscribers (
  type idx_subs_email (line 31) | CREATE UNIQUE INDEX idx_subs_email ON subscribers(LOWER(email))
  type idx_subs_status (line 32) | CREATE INDEX idx_subs_status ON subscribers(status)
  type idx_subs_id_status (line 33) | CREATE INDEX idx_subs_id_status ON subscribers(id, status)
  type idx_subs_created_at (line 34) | CREATE INDEX idx_subs_created_at ON subscribers(created_at)
  type idx_subs_updated_at (line 35) | CREATE INDEX idx_subs_updated_at ON subscribers(updated_at)
  type lists (line 39) | CREATE TABLE lists (
  type idx_lists_type (line 52) | CREATE INDEX idx_lists_type ON lists(type)
  type idx_lists_optin (line 53) | CREATE INDEX idx_lists_optin ON lists(optin)
  type idx_lists_status (line 54) | CREATE INDEX idx_lists_status ON lists(status)
  type idx_lists_name (line 55) | CREATE INDEX idx_lists_name ON lists(name)
  type idx_lists_created_at (line 56) | CREATE INDEX idx_lists_created_at ON lists(created_at)
  type idx_lists_updated_at (line 57) | CREATE INDEX idx_lists_updated_at ON lists(updated_at)
  type subscriber_lists (line 61) | CREATE TABLE subscriber_lists (
  type idx_sub_lists_sub_id (line 72) | CREATE INDEX idx_sub_lists_sub_id ON subscriber_lists(subscriber_id)
  type idx_sub_lists_list_id (line 73) | CREATE INDEX idx_sub_lists_list_id ON subscriber_lists(list_id)
  type idx_sub_lists_status (line 74) | CREATE INDEX idx_sub_lists_status ON subscriber_lists(status)
  type templates (line 78) | CREATE TABLE templates (
  type templates (line 90) | CREATE UNIQUE INDEX ON templates (is_default) WHERE is_default = true
  type campaigns (line 95) | CREATE TABLE campaigns (
  type idx_camps_status (line 135) | CREATE INDEX idx_camps_status ON campaigns(status)
  type idx_camps_name (line 136) | CREATE INDEX idx_camps_name ON campaigns(name)
  type idx_camps_created_at (line 137) | CREATE INDEX idx_camps_created_at ON campaigns(created_at)
  type idx_camps_updated_at (line 138) | CREATE INDEX idx_camps_updated_at ON campaigns(updated_at)
  type campaign_lists (line 142) | CREATE TABLE campaign_lists (
  type campaign_lists (line 151) | CREATE UNIQUE INDEX ON campaign_lists (campaign_id, list_id)
  type idx_camp_lists_camp_id (line 152) | CREATE INDEX idx_camp_lists_camp_id ON campaign_lists(campaign_id)
  type idx_camp_lists_list_id (line 153) | CREATE INDEX idx_camp_lists_list_id ON campaign_lists(list_id)
  type campaign_views (line 156) | CREATE TABLE campaign_views (
  type idx_views_camp_id (line 164) | CREATE INDEX idx_views_camp_id ON campaign_views(campaign_id)
  type idx_views_subscriber_id (line 165) | CREATE INDEX idx_views_subscriber_id ON campaign_views(subscriber_id)
  type idx_views_date (line 166) | CREATE INDEX idx_views_date ON campaign_views(created_at)
  type media (line 170) | CREATE TABLE media (
  type idx_media_filename (line 180) | CREATE INDEX idx_media_filename ON media(provider, filename)
  type campaign_media (line 184) | CREATE TABLE campaign_media (
  type idx_camp_media_id (line 193) | CREATE UNIQUE INDEX idx_camp_media_id ON campaign_media (campaign_id, me...
  type idx_camp_media_camp_id (line 194) | CREATE INDEX idx_camp_media_camp_id ON campaign_media(campaign_id)
  type links (line 199) | CREATE TABLE links (
  type link_clicks (line 207) | CREATE TABLE link_clicks (
  type idx_clicks_camp_id (line 216) | CREATE INDEX idx_clicks_camp_id ON link_clicks(campaign_id)
  type idx_clicks_link_id (line 217) | CREATE INDEX idx_clicks_link_id ON link_clicks(link_id)
  type idx_clicks_sub_id (line 218) | CREATE INDEX idx_clicks_sub_id ON link_clicks(subscriber_id)
  type idx_clicks_date (line 219) | CREATE INDEX idx_clicks_date ON link_clicks(created_at)
  type settings (line 223) | CREATE TABLE settings (
  type idx_settings_key (line 228) | CREATE INDEX idx_settings_key ON settings(key)
  type bounces (line 302) | CREATE TABLE bounces (
  type idx_bounces_sub_id (line 311) | CREATE INDEX idx_bounces_sub_id ON bounces(subscriber_id)
  type idx_bounces_camp_id (line 312) | CREATE INDEX idx_bounces_camp_id ON bounces(campaign_id)
  type idx_bounces_source (line 313) | CREATE INDEX idx_bounces_source ON bounces(source)
  type idx_bounces_date (line 314) | CREATE INDEX idx_bounces_date ON bounces(created_at)
  type roles (line 318) | CREATE TABLE roles (
  type idx_roles (line 328) | CREATE UNIQUE INDEX idx_roles ON roles (parent_id, list_id)
  type idx_roles_name (line 329) | CREATE UNIQUE INDEX idx_roles_name ON roles (type, name) WHERE name IS N...
  type users (line 333) | CREATE TABLE users (
  type sessions (line 354) | CREATE TABLE sessions (
  type idx_sessions (line 359) | CREATE INDEX idx_sessions ON sessions (id, created_at)
  type mat_dashboard_stats_idx (line 396) | CREATE UNIQUE INDEX mat_dashboard_stats_idx ON mat_dashboard_counts (upd...
  type mat_dashboard_charts_idx (line 432) | CREATE UNIQUE INDEX mat_dashboard_charts_idx ON mat_dashboard_charts (up...
  type mat_list_subscriber_stats_idx (line 442) | CREATE UNIQUE INDEX mat_list_subscriber_stats_idx ON mat_list_subscriber...

FILE: scripts/translate-i18n.py
  function translate (line 20) | def translate(data, lang):
Condensed preview — 427 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (7,619K chars).
[
  {
    "path": ".devcontainer/devcontainer.json",
    "chars": 259,
    "preview": "{\n  \"name\": \"listmonk\",\n  \"dockerComposeFile\": \"../dev/docker-compose.yml\",\n  \"service\": \"backend\",\n  \"workspaceFolder\":"
  },
  {
    "path": ".dockerignore",
    "chars": 316,
    "preview": "**/.classpath\n**/.dockerignore\n**/.env\n**/.git\n**/.gitignore\n**/.project\n**/.settings\n**/.toolstarget\n**/.vs\n**/.vscode\n"
  },
  {
    "path": ".gitattributes",
    "chars": 69,
    "preview": "frontend/* linguist-vendored\nVERSION export-subst\n* text=auto eol=lf\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/confirmed-bug.md",
    "chars": 381,
    "preview": "---\nname: Confirmed bug\nabout: Report an issue that you have definititely confirmed to be a bug\ntitle: ''\nlabels: bug\nas"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-or-change-request.md",
    "chars": 404,
    "preview": "---\nname: Feature or change request\nabout: Suggest new features or changes to existing features\ntitle: ''\nlabels: enhanc"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/general-question.md",
    "chars": 441,
    "preview": "---\nname: General question\nabout: You have a question about something or want to start a general discussion\ntitle: ''\nla"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md",
    "chars": 422,
    "preview": "---\nname: Possible bug. Needs investigation.\nabout: Report an issue that could be a bug but is not confirmed yet and nee"
  },
  {
    "path": ".github/workflows/build-sanity.yml",
    "chars": 370,
    "preview": "name: Build Sanity Check\n\non:\n    pull_request:\n      types:\n        - opened\n  \njobs:\n  build:\n    runs-on: ubuntu-late"
  },
  {
    "path": ".github/workflows/github-pages.yml",
    "chars": 1868,
    "preview": "name: publish-github-pages\n\non:\n  push:\n    branches:\n      - master\n    paths:\n      - 'docs/**'\n  workflow_dispatch:\n\n"
  },
  {
    "path": ".github/workflows/hodor-review.yml",
    "chars": 918,
    "preview": "name: Hodor AI Code Review\n\non:\n  pull_request_target:\n    types: [labeled, synchronize]\n    paths-ignore:\n      - 'i18n"
  },
  {
    "path": ".github/workflows/issues.yml",
    "chars": 714,
    "preview": "name: \"close-stale-issues-and-prs\"\non:\n  schedule:\n    - cron: \"30 1 * * *\"\n  workflow_dispatch:\n\njobs:\n  stale:\n    run"
  },
  {
    "path": ".github/workflows/nightly.yml",
    "chars": 4973,
    "preview": "name: nightly\non:\n  schedule:\n    - cron: \"0 2 * * *\"\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  packages: w"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 1265,
    "preview": "name: goreleaser\n\non:\n  push:\n    tags:\n      - \"v*\" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0"
  },
  {
    "path": ".gitignore",
    "chars": 374,
    "preview": "frontend/node_modules/\nfrontend/.cache/\nfrontend/yarn.lock\nfrontend/build/\nfrontend/public/static/email-builder/\nfronten"
  },
  {
    "path": ".go-version",
    "chars": 7,
    "preview": "1.26.1\n"
  },
  {
    "path": ".goreleaser-nightly.yml",
    "chars": 6192,
    "preview": "version: 2\n\nsnapshot:\n  version_template: \"{{ .Env.LISTMONK_VERSION }}\"\n\n# GoReleaser config for nightly builds\n\nenv:\n  "
  },
  {
    "path": ".goreleaser.yml",
    "chars": 6954,
    "preview": "env:\n  - GO111MODULE=on\n  - CGO_ENABLED=0\n  - GITHUB_ORG=knadh\n  - DOCKER_ORG=listmonk\n\nbefore:\n  hooks:\n    - make buil"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 5130,
    "preview": "# 1. Contributing\n\nWelcome to listmonk! You can contribute to the project in the following ways:\n\n1. **Bug reports:** On"
  },
  {
    "path": "Dockerfile",
    "chars": 563,
    "preview": "FROM alpine:latest\n\n# Install dependencies\nRUN apk --no-cache add ca-certificates tzdata shadow su-exec\n\n# Set the worki"
  },
  {
    "path": "LICENSE",
    "chars": 34520,
    "preview": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C)"
  },
  {
    "path": "Makefile",
    "chars": 5806,
    "preview": "# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.\nLAST_COMMIT := $(or $(shell git rev-parse --sh"
  },
  {
    "path": "README.md",
    "chars": 2138,
    "preview": "<a href=\"https://zerodha.tech\"><img src=\"https://zerodha.tech/static/images/github-badge.svg\" align=\"right\" /></a>\n\n[![l"
  },
  {
    "path": "SECURITY.md",
    "chars": 181,
    "preview": "# Reporting security issues\n\nPlease refer to https://listmonk.app/docs/security-reports/ first to see the list of non-is"
  },
  {
    "path": "VERSION",
    "chars": 24,
    "preview": "$Format:%h$\n$Format:%D$\n"
  },
  {
    "path": "cmd/admin.go",
    "chars": 3874,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/knadh/listmonk/internal/capt"
  },
  {
    "path": "cmd/archive.go",
    "chars": 7669,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/gorilla/feeds\"\n\t\""
  },
  {
    "path": "cmd/auth.go",
    "chars": 23865,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/mail"
  },
  {
    "path": "cmd/bounce.go",
    "chars": 7465,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/knadh/listmonk/models\"\n\t\"gith"
  },
  {
    "path": "cmd/campaigns.go",
    "chars": 23717,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"st"
  },
  {
    "path": "cmd/events.go",
    "chars": 1075,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/labstack/echo/v4\"\n)\n\n// EventStream serves a"
  },
  {
    "path": "cmd/handlers.go",
    "chars": 17356,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"regexp\"\n\t\"strconv\"\n\n\t\"github.com/knadh/listmonk/interna"
  },
  {
    "path": "cmd/i18n.go",
    "chars": 2555,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"sort\"\n\n\t\"github.com/knadh/listmonk/internal/i18n\""
  },
  {
    "path": "cmd/import.go",
    "chars": 4129,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/knadh/listmonk/internal/subimpo"
  },
  {
    "path": "cmd/init.go",
    "chars": 36044,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"crypto/md5\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io\"\n\t"
  },
  {
    "path": "cmd/install.go",
    "chars": 10687,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/gofrs/uuid/v5\"\n\t\"github.com/jmoiron/sqlx\"\n"
  },
  {
    "path": "cmd/lists.go",
    "chars": 5502,
    "preview": "package main\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/knadh/listmonk/internal/auth\"\n\t\"github.com/knadh/"
  },
  {
    "path": "cmd/main.go",
    "chars": 9557,
    "preview": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github"
  },
  {
    "path": "cmd/maintenance.go",
    "chars": 2404,
    "preview": "package main\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/labstack/echo/v4\"\n)\n\n// GCSub"
  },
  {
    "path": "cmd/manager_store.go",
    "chars": 4485,
    "preview": "package main\n\nimport (\n\t\"github.com/gofrs/uuid/v5\"\n\t\"github.com/knadh/listmonk/internal/core\"\n\t\"github.com/knadh/listmon"
  },
  {
    "path": "cmd/media.go",
    "chars": 5990,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/disintegration/i"
  },
  {
    "path": "cmd/public.go",
    "chars": 25685,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"image\"\n\t\"image/png\"\n\t\"io\"\n\t\"net/http\"\n\t\"strcon"
  },
  {
    "path": "cmd/roles.go",
    "chars": 4518,
    "preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/knadh/listmonk/internal/auth\"\n\t\"github.com/labstack/e"
  },
  {
    "path": "cmd/settings.go",
    "chars": 12639,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"syscall\""
  },
  {
    "path": "cmd/subscribers.go",
    "chars": 23124,
    "preview": "package main\n\nimport (\n\t\"encoding/csv\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/textproto\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"stri"
  },
  {
    "path": "cmd/templates.go",
    "chars": 8119,
    "preview": "package main\n\nimport (\n\t\"errors\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/knadh/listmo"
  },
  {
    "path": "cmd/tx.go",
    "chars": 7056,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/textproto\"\n\t\"strings\"\n\n\t\"github.com/knadh/listmon"
  },
  {
    "path": "cmd/updates.go",
    "chars": 2411,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"time\"\n\n\t\"golang.org/x/mod/semver\"\n)\n\nconst updateC"
  },
  {
    "path": "cmd/upgrade.go",
    "chars": 4931,
    "preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/knadh/koanf/v2\"\n\t\"github.com/kn"
  },
  {
    "path": "cmd/users.go",
    "chars": 10189,
    "preview": "package main\n\nimport (\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/knadh/listmonk/internal/auth\"\n\t\"github.com/knadh/l"
  },
  {
    "path": "cmd/utils.go",
    "chars": 2744,
    "preview": "package main\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nva"
  },
  {
    "path": "config.toml.sample",
    "chars": 695,
    "preview": "[app]\n# Interface and port where the app will run its webserver.  The default value\n# of localhost will only listen to c"
  },
  {
    "path": "dev/.gitignore",
    "chars": 13,
    "preview": "!config.toml\n"
  },
  {
    "path": "dev/README.md",
    "chars": 1713,
    "preview": "# Docker suite for development\n\n**NOTE**: This exists only for local development. If you're interested in using\nDocker f"
  },
  {
    "path": "dev/app.Dockerfile",
    "chars": 208,
    "preview": "FROM golang:1.24.1 AS go\n\nFROM node:16 AS node\n\nCOPY --from=go /usr/local/go /usr/local/go\nENV GOPATH /go\nENV CGO_ENABLE"
  },
  {
    "path": "dev/config.toml",
    "chars": 698,
    "preview": "[app]\n# Interface and port where the app will run its webserver.  The default value\n# of localhost will only listen to c"
  },
  {
    "path": "dev/docker-compose.yml",
    "chars": 1304,
    "preview": "version: \"3\"\n\nservices:\n  adminer:\n    image: adminer:4.8.1-standalone\n    restart: always\n    ports:\n      - 8070:8080\n"
  },
  {
    "path": "docker-compose.yml",
    "chars": 3396,
    "preview": "# All LISTMONK_* env variables also support the LISTMONK_*_FILE pattern for loading secrets from files with Docker secre"
  },
  {
    "path": "docker-entrypoint.sh",
    "chars": 2260,
    "preview": "#!/bin/sh\n\nset -e\n\nexport PUID=${PUID:-0}\nexport PGID=${PGID:-0}\nexport GROUP_NAME=\"app\"\nexport USER_NAME=\"app\"\n\n# This "
  },
  {
    "path": "docs/README.md",
    "chars": 440,
    "preview": "# Static website and docs\n\nThis repository contains the source for the static website https://listmonk.app\n\n- The websit"
  },
  {
    "path": "docs/docs/content/apis/apis.md",
    "chars": 3711,
    "preview": "# APIs\n\nAll features that are available on the listmonk dashboard are also available as REST-like HTTP APIs that can be "
  },
  {
    "path": "docs/docs/content/apis/bounces.md",
    "chars": 4263,
    "preview": "# API / Bounces\n\nMethod   | Endpoint                                                | Description\n---------|------------"
  },
  {
    "path": "docs/docs/content/apis/campaigns.md",
    "chars": 20010,
    "preview": "# API / Campaigns\n\n| Method | Endpoint                                                                    | Description "
  },
  {
    "path": "docs/docs/content/apis/import.md",
    "chars": 3989,
    "preview": "# API / Import\n\nMethod   | Endpoint                                        | Description\n---------|---------------------"
  },
  {
    "path": "docs/docs/content/apis/lists.md",
    "chars": 10321,
    "preview": "# API / Lists\n\n| Method | Endpoint                                        | Description               |\n| :----- | :----"
  },
  {
    "path": "docs/docs/content/apis/media.md",
    "chars": 3458,
    "preview": "# API / Media\n\nMethod | Endpoint                                             | Description\n-------|---------------------"
  },
  {
    "path": "docs/docs/content/apis/sdks.md",
    "chars": 2017,
    "preview": "# SDKs and client libraries\n\nA list of 3rd party client libraries and SDKs that have been written for listmonk APIs.\n\n!!"
  },
  {
    "path": "docs/docs/content/apis/subscribers.md",
    "chars": 21521,
    "preview": "# API / Subscribers\n\n| Method | Endpoint                                                                                "
  },
  {
    "path": "docs/docs/content/apis/templates.md",
    "chars": 7460,
    "preview": "# API / Templates\n\n| Method | Endpoint                                                                      | Descriptio"
  },
  {
    "path": "docs/docs/content/apis/transactional.md",
    "chars": 5428,
    "preview": "# API / Transactional\n\n| Method | Endpoint | Description                 |\n| :----- | :------- | :----------------------"
  },
  {
    "path": "docs/docs/content/archives.md",
    "chars": 1123,
    "preview": "# Archives\n\nA global public archive is maintained on the public web interface. It can be\nenabled under Settings -> Setti"
  },
  {
    "path": "docs/docs/content/bounces.md",
    "chars": 7448,
    "preview": "# Bounce processing\n\nEnable bounce processing in Settings -> Bounces. POP3 bounce scanning and APIs only become availabl"
  },
  {
    "path": "docs/docs/content/concepts.md",
    "chars": 5659,
    "preview": "# Concepts\n\n## Subscriber\n\nA subscriber is a recipient identified by an e-mail address and name. Subscribers receive e-m"
  },
  {
    "path": "docs/docs/content/configuration.md",
    "chars": 6320,
    "preview": "# Configuration\n\n### TOML Configuration file\nOne or more TOML files can be read by passing `--config config.toml` multip"
  },
  {
    "path": "docs/docs/content/developer-setup.md",
    "chars": 2037,
    "preview": "# Developer setup\nThe app has two distinct components, the Go backend and the VueJS frontend. In the dev environment, bo"
  },
  {
    "path": "docs/docs/content/external-integration.md",
    "chars": 922,
    "preview": "# Integrating with external systems\n\nIn many environments, a mailing list manager's subscriber database is not run indep"
  },
  {
    "path": "docs/docs/content/i18n.md",
    "chars": 2142,
    "preview": "# Internationalization (i18n)\n\nlistmonk comes available in multiple languages thanks to language packs contributed by vo"
  },
  {
    "path": "docs/docs/content/index.md",
    "chars": 707,
    "preview": "# Introduction\n\n[![listmonk](images/logo.svg)](https://listmonk.app)\n\nlistmonk is a self-hosted, high performance one-wa"
  },
  {
    "path": "docs/docs/content/installation.md",
    "chars": 7840,
    "preview": "# Installation\n\nlistmonk is a simple binary application that requires a Postgres database instance to run. The binary ca"
  },
  {
    "path": "docs/docs/content/maintenance/performance.md",
    "chars": 2017,
    "preview": "# Performance\n\nlistmonk is built to be highly performant and can handle millions of subscribers with minimal system reso"
  },
  {
    "path": "docs/docs/content/messengers.md",
    "chars": 2350,
    "preview": "# Messengers\n\nlistmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, ena"
  },
  {
    "path": "docs/docs/content/oidc.md",
    "chars": 5606,
    "preview": "\n## OIDC Single Sign On\n\nListmonk supports single sign-on with OIDC (OpenID Connect). Any standards compliant OIDC provi"
  },
  {
    "path": "docs/docs/content/querying-and-segmentation.md",
    "chars": 3682,
    "preview": "# Querying and segmenting subscribers\n\nlistmonk allows the writing of partial Postgres SQL expressions to query, filter,"
  },
  {
    "path": "docs/docs/content/roles-and-permissions.md",
    "chars": 10349,
    "preview": "listmonk supports (>= v4.0.0) creating systems users with granular permissions to various features, including list-speci"
  },
  {
    "path": "docs/docs/content/security-reports.md",
    "chars": 3007,
    "preview": "If you spot a security vulnerability in listmonk, please report it via GitHub [security advisories](https://github.com/k"
  },
  {
    "path": "docs/docs/content/static/style.css",
    "chars": 2360,
    "preview": "body[data-md-color-primary=\"white\"] .md-header[data-md-state=\"shadow\"] {\n  background: #fff;\n  box-shadow: none;\n  color"
  },
  {
    "path": "docs/docs/content/templating.md",
    "chars": 11734,
    "preview": "# Templating\n\nA template is a re-usable HTML design that can be used across campaigns and transactional messages. Most c"
  },
  {
    "path": "docs/docs/content/upgrade.md",
    "chars": 4568,
    "preview": "# Upgrade\n\n!!! Warning\n    Always take a backup of the Postgres database before upgrading listmonk\n\n## Binary\n- Stop the"
  },
  {
    "path": "docs/docs/mkdocs.yml",
    "chars": 1823,
    "preview": "site_name: listmonk / Documentation\ntheme:\n  name: material\n  # custom_dir: \"mkdocs-material/material\"\n  logo: \"images/f"
  },
  {
    "path": "docs/docs/requirements.txt",
    "chars": 98,
    "preview": "mkdocs>=1.6.1\nmkdocs-material>=9.6.14\nmkdocs-material-extensions>=1.3.1\npymdown-extensions>=10.15\n"
  },
  {
    "path": "docs/i18n/index.html",
    "chars": 4099,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n\t<title>listmonk i18n translation editor</title>\n\t<meta http-equiv=\"Content-Type"
  },
  {
    "path": "docs/i18n/main.js",
    "chars": 4421,
    "preview": "const BASEURL = \"https://raw.githubusercontent.com/knadh/listmonk/master/i18n/\";\nconst BASELANG = \"en\";\n\nvar app = new V"
  },
  {
    "path": "docs/i18n/style.css",
    "chars": 1523,
    "preview": "* {\n\tbox-sizing: border-box;\n}\n\nbody {\n\tfont-family: Inter, \"Helvetica Neue\", \"Segoe UI\", sans-serif;\n\tfont-size: 16px;\n"
  },
  {
    "path": "docs/site/content/.gitignore",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "docs/site/data/github.json",
    "chars": 848,
    "preview": "{\"version\":\"v6.0.0\",\"date\":\"2026-01-02T17:51:28Z\",\"url\":\"https://github.com/knadh/listmonk/releases/tag/v6.0.0\",\"assets\""
  },
  {
    "path": "docs/site/layouts/index.html",
    "chars": 9442,
    "preview": "{{ partial \"header.html\" . }}\n<div class=\"splash container center\">\n      <img class=\"s4\" src=\"static/images/s4.png\" />\n"
  },
  {
    "path": "docs/site/layouts/page/single.html",
    "chars": 121,
    "preview": "{{ partial \"header\" . }}\n<article class=\"page\">\n\t<h1>{{ .Title }}</h1> \n\t{{ .Content }}\n</article>\n{{ partial \"footer\" }"
  },
  {
    "path": "docs/site/layouts/partials/footer.html",
    "chars": 226,
    "preview": "\n  <footer class=\"container footer\">\n    &copy; 2018-{{ now.Format \"2006\" }} / <a href=\"https://nadh.in\">Kailash Nadh</a"
  },
  {
    "path": "docs/site/layouts/partials/header.html",
    "chars": 2092,
    "preview": "<!DOCTYPE html>\n<html>\n<head>\n  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n  <title>{{ .Title"
  },
  {
    "path": "docs/site/layouts/shortcodes/centered.html",
    "chars": 130,
    "preview": "<section class=\"row\">\n\t<div class=\"col2\">&nbsp;</div>\n\t<div class=\"col8\">{{ .Inner }}</div>\n\t<div class=\"clear\"> </div>\n"
  },
  {
    "path": "docs/site/layouts/shortcodes/github.html",
    "chars": 556,
    "preview": "<ul id=\"github\" class=\"no\">\n    {{ range .Page.Site.Data.github }}\n        <li class=\"row\">\n            <div class=\"col2"
  },
  {
    "path": "docs/site/layouts/shortcodes/half.html",
    "chars": 90,
    "preview": "<div class=\"row\">\n\t<div class=\"col7\">{{ .Inner }}</div>\n\t<div class=\"clear\"> </div>\n</div>"
  },
  {
    "path": "docs/site/layouts/shortcodes/section.html",
    "chars": 34,
    "preview": "<section>\n\t{{ .Inner }}\n</section>"
  },
  {
    "path": "docs/site/static/static/base.css",
    "chars": 1856,
    "preview": "/**\n*** SIMPLE GRID\n*** (C) ZACH COLE 2016\n**/\n\n\n/* UNIVERSAL */\n\nhtml,\nbody {\n  height: 100%;\n  width: 100%;\n  margin: "
  },
  {
    "path": "docs/site/static/static/style.css",
    "chars": 4744,
    "preview": "body {\n  background: #fdfdfd;\n  font-family: \"Inter\", \"Helvetica Neue\", \"Segoe UI\", sans-serif;\n  font-size: 17px;\n  fon"
  },
  {
    "path": "docs/swagger/collections.yaml",
    "chars": 116093,
    "preview": "openapi: 3.0.0\n\nservers:\n  - description: Listmonk Developement Server\n    url: http://localhost:9000/api\n\ninfo:\n  versi"
  },
  {
    "path": "frontend/.browserslistrc",
    "chars": 30,
    "preview": "> 1%\nlast 2 versions\nnot dead\n"
  },
  {
    "path": "frontend/.editorconfig",
    "chars": 160,
    "preview": "[*.{js,jsx,ts,tsx,vue}]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ntrim_trailing_whitespace = true\ninsert_fin"
  },
  {
    "path": "frontend/.eslintrc.js",
    "chars": 808,
    "preview": "module.exports = {\n  root: true,\n  env: {\n    node: true,\n    // es2022: true,\n  },\n  plugins: ['vue'],\n  extends: [\n   "
  },
  {
    "path": "frontend/.gitignore",
    "chars": 230,
    "preview": ".DS_Store\nnode_modules\n/dist\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn"
  },
  {
    "path": "frontend/README.md",
    "chars": 2587,
    "preview": "# listmonk frontend (Vue + Buefy)\n\nIt's best if the `listmonk/frontend` directory is opened in an IDE as a separate proj"
  },
  {
    "path": "frontend/babel.config.js",
    "chars": 76,
    "preview": "module.exports = {\n  presets: [\n    '@vue/cli-plugin-babel/preset',\n  ],\n};\n"
  },
  {
    "path": "frontend/cypress/e2e/archive.cy.js",
    "chars": 2529,
    "preview": "const apiUrl = Cypress.env('apiUrl');\n\ndescribe('Archive', () => {\n  it('Opens campaigns page', () => {\n    cy.resetDB()"
  },
  {
    "path": "frontend/cypress/e2e/bounces.cy.js",
    "chars": 2442,
    "preview": "const apiUrl = Cypress.env('apiUrl');\n\ndescribe('Bounces', () => {\n  const subs = [];\n\n  it('Enable bounces', () => {\n  "
  },
  {
    "path": "frontend/cypress/e2e/campaigns.cy.js",
    "chars": 14245,
    "preview": "const apiUrl = Cypress.env('apiUrl');\nconst headers = '[{\"X-Custom\": \"Custom-Value\"}]';\n\ndescribe('Campaigns', () => {\n "
  },
  {
    "path": "frontend/cypress/e2e/dashboard.cy.js",
    "chars": 785,
    "preview": "describe('Dashboard', () => {\n  it('Opens dashboard', () => {\n    cy.resetDB();\n    cy.loginAndVisit('/');\n\n    // List "
  },
  {
    "path": "frontend/cypress/e2e/forms.cy.js",
    "chars": 4738,
    "preview": "const apiUrl = Cypress.env('apiUrl');\n\ndescribe('Forms', () => {\n  it('Opens forms page', () => {\n    cy.resetDB();\n    "
  },
  {
    "path": "frontend/cypress/e2e/import.cy.js",
    "chars": 2926,
    "preview": "describe('Import', () => {\n  it('Opens import page', () => {\n    cy.resetDB();\n    cy.loginAndVisit('/admin/subscribers/"
  },
  {
    "path": "frontend/cypress/e2e/lists.cy.js",
    "chars": 6389,
    "preview": "describe('Lists', () => {\n  it('Opens lists page', () => {\n    cy.resetDB();\n    cy.loginAndVisit('/admin/lists');\n  });"
  },
  {
    "path": "frontend/cypress/e2e/settings.cy.js",
    "chars": 1463,
    "preview": "const apiUrl = Cypress.env('apiUrl');\n\ndescribe('Settings', () => {\n  it('Opens settings page', () => {\n    cy.resetDB()"
  },
  {
    "path": "frontend/cypress/e2e/subscribers.cy.js",
    "chars": 12764,
    "preview": "const apiUrl = Cypress.env('apiUrl');\n\ndescribe('Subscribers', () => {\n  it('Opens subscribers page', () => {\n    cy.res"
  },
  {
    "path": "frontend/cypress/e2e/templates.cy.js",
    "chars": 3706,
    "preview": "describe('Templates', () => {\n  it('Opens templates page', () => {\n    cy.resetDB();\n    cy.loginAndVisit('/admin/campai"
  },
  {
    "path": "frontend/cypress/e2e/users.cy.js",
    "chars": 5827,
    "preview": "const apiUrl = Cypress.env('apiUrl');\n\ndescribe('First time user setup', () => {\n  it('Sets up the superadmin user', () "
  },
  {
    "path": "frontend/cypress/fixtures/subs-domain-blocklist.csv",
    "chars": 429,
    "preview": "email,name,attributes\nnoban1-import@mail.com,First0 Last0,\"{\"\"age\"\": 29, \"\"city\"\": \"\"Bangalore\"\", \"\"clientId\"\": \"\"DAXX79"
  },
  {
    "path": "frontend/cypress/fixtures/subs.csv",
    "chars": 9893,
    "preview": "email,name,attributes\r\nuser0@mail.com,First0 Last0,\"{\"\"age\"\": 29, \"\"city\"\": \"\"Bangalore\"\", \"\"clientId\"\": \"\"DAXX79\"\"}\"\r\nu"
  },
  {
    "path": "frontend/cypress/plugins/index.js",
    "chars": 718,
    "preview": "/// <reference types=\"cypress\" />\n// ***********************************************************\n// This example plugins"
  },
  {
    "path": "frontend/cypress/support/commands.js",
    "chars": 2556,
    "preview": "import 'cypress-file-upload';\nimport 'cypress-wait-until';\n\nCypress.Commands.add('resetDB', () => {\n  // Although cypres"
  },
  {
    "path": "frontend/cypress/support/e2e.js",
    "chars": 196,
    "preview": "import './commands';\n\nbeforeEach(() => {\n  cy.intercept('GET', '/sockjs-node/**', (req) => {\n    req.destroy();\n  });\n\n "
  },
  {
    "path": "frontend/cypress/support/reset.sh",
    "chars": 103,
    "preview": "#!/bin/bash\n\npkill -9 listmonk\n cd ../\n./listmonk --install --yes\n./listmonk > /dev/null 2>/dev/null &\n"
  },
  {
    "path": "frontend/cypress.config.js",
    "chars": 943,
    "preview": "const { defineConfig } = require('cypress');\n\nmodule.exports = defineConfig({\n  env: {\n    apiUrl: 'http://localhost:900"
  },
  {
    "path": "frontend/email-builder/LICENSE",
    "chars": 1087,
    "preview": "MIT License\n\nCopyright (c) 2024 Waypoint (Metaccountant, Inc.)\n\nPermission is hereby granted, free of charge, to any per"
  },
  {
    "path": "frontend/email-builder/README.md",
    "chars": 276,
    "preview": "# @usewaypoint/editor-sample\n\nUse this as a sample to self-host EmailBuilder.js.\n\nTo run this locally, fork the reposito"
  },
  {
    "path": "frontend/email-builder/index.html",
    "chars": 2433,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"stylesheet\" href=\"https://cdnjs.cl"
  },
  {
    "path": "frontend/email-builder/package.json",
    "chars": 1734,
    "preview": "{\n  \"name\": \"@listmonk/email-builder\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \""
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/index.tsx",
    "chars": 3363,
    "preview": "import React from 'react';\n\nimport { Box, Typography } from '@mui/material';\n\nimport { TEditorBlock } from '../../../doc"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/AvatarSidebarPanel.tsx",
    "chars": 2674,
    "preview": "import React, { useState } from 'react';\n\nimport { AspectRatioOutlined } from '@mui/icons-material';\nimport { ToggleButt"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ButtonSidebarPanel.tsx",
    "chars": 3713,
    "preview": "import React, { useState } from 'react';\n\nimport { ToggleButton } from '@mui/material';\nimport { ButtonProps, ButtonProp"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ColumnsContainerSidebarPanel.tsx",
    "chars": 3062,
    "preview": "import React, { useState } from 'react';\n\nimport {\n  SpaceBarOutlined,\n  VerticalAlignBottomOutlined,\n  VerticalAlignCen"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ContainerSidebarPanel.tsx",
    "chars": 1073,
    "preview": "import React, { useState } from 'react';\n\nimport ContainerPropsSchema, { ContainerProps } from '../../../../documents/bl"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/DividerSidebarPanel.tsx",
    "chars": 1835,
    "preview": "import React, { useState } from 'react';\n\nimport { HeightOutlined } from '@mui/icons-material';\nimport { DividerProps, D"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/EmailLayoutSidebarPanel.tsx",
    "chars": 2283,
    "preview": "import React, { useState } from 'react';\n\nimport { RoundedCornerOutlined } from '@mui/icons-material';\n\nimport EmailLayo"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/HeadingSidebarPanel.tsx",
    "chars": 1882,
    "preview": "import React, { useState } from 'react';\n\nimport { ToggleButton } from '@mui/material';\nimport { HeadingProps, HeadingPr"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/HtmlSidebarPanel.tsx",
    "chars": 1272,
    "preview": "import React, { useState } from 'react';\n\nimport { HtmlProps, HtmlPropsSchema } from '@usewaypoint/block-html';\n\nimport "
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ImageSidebarPanel.tsx",
    "chars": 3597,
    "preview": "import React, { useState } from 'react';\nimport CloudUploadIcon from '@mui/icons-material/CloudUpload';\nimport {\n  Verti"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/SpacerSidebarPanel.tsx",
    "chars": 1206,
    "preview": "import React, { useState } from 'react';\n\nimport { HeightOutlined } from '@mui/icons-material';\nimport { SpacerProps, Sp"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/TextSidebarPanel.tsx",
    "chars": 1533,
    "preview": "import React, { useState } from 'react';\n\nimport { TextProps, TextPropsSchema } from '@usewaypoint/block-text';\n\nimport "
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/BaseSidebarPanel.tsx",
    "chars": 486,
    "preview": "import React from 'react';\n\nimport { Box, Stack, Typography } from '@mui/material';\n\ntype SidebarPanelProps = {\n  title:"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/BooleanInput.tsx",
    "chars": 599,
    "preview": "import React, { useState } from 'react';\n\nimport { FormControlLabel, Switch } from '@mui/material';\n\ntype Props = {\n  la"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/BaseColorInput.tsx",
    "chars": 2280,
    "preview": "import React, { useState } from 'react';\n\nimport { AddOutlined, CloseOutlined } from '@mui/icons-material';\nimport { But"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Picker.tsx",
    "chars": 1621,
    "preview": "import React from 'react';\nimport { HexColorInput, HexColorPicker } from 'react-colorful';\n\nimport { Box, Stack, SxProps"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Swatch.tsx",
    "chars": 1031,
    "preview": "import React from 'react';\n\nimport { Box, Button, SxProps } from '@mui/material';\n\ntype Props = {\n  paletteColors: strin"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/index.tsx",
    "chars": 511,
    "preview": "import React from 'react';\n\nimport BaseColorInput from './BaseColorInput';\n\ntype Props = {\n  label: string;\n  onChange: "
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColumnWidthsInput.tsx",
    "chars": 1772,
    "preview": "import React, { useState } from 'react';\n\nimport { Stack } from '@mui/material';\n\nimport TextDimensionInput from './Text"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontFamily.tsx",
    "chars": 969,
    "preview": "import React, { useState } from 'react';\n\nimport { MenuItem, TextField } from '@mui/material';\n\nimport { FONT_FAMILIES }"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontSizeInput.tsx",
    "chars": 866,
    "preview": "import React, { useState } from 'react';\n\nimport { TextFieldsOutlined } from '@mui/icons-material';\nimport { InputLabel,"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontWeightInput.tsx",
    "chars": 700,
    "preview": "import React, { useState } from 'react';\n\nimport { ToggleButton } from '@mui/material';\n\nimport RadioGroupInput from './"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/PaddingInput.tsx",
    "chars": 2187,
    "preview": "import React, { useState } from 'react';\n\nimport {\n  AlignHorizontalLeftOutlined,\n  AlignHorizontalRightOutlined,\n  Alig"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/RadioGroupInput.tsx",
    "chars": 903,
    "preview": "import React, { useState } from 'react';\n\nimport { InputLabel, Stack, ToggleButtonGroup } from '@mui/material';\n\ntype Pr"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/SliderInput.tsx",
    "chars": 817,
    "preview": "import React, { useState } from 'react';\n\nimport { InputLabel, Stack } from '@mui/material';\n\nimport RawSliderInput from"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextAlignInput.tsx",
    "chars": 1047,
    "preview": "import React, { useState } from 'react';\n\nimport { FormatAlignCenterOutlined, FormatAlignLeftOutlined, FormatAlignRightO"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextDimensionInput.tsx",
    "chars": 869,
    "preview": "import React from 'react';\n\nimport { TextField, Typography } from '@mui/material';\n\ntype TextDimensionInputProps = {\n  l"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextInput.tsx",
    "chars": 994,
    "preview": "import React, { useState } from 'react';\n\nimport { InputProps, TextField } from '@mui/material';\n\ntype Props = {\n  label"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/raw/RawSliderInput.tsx",
    "chars": 1119,
    "preview": "import React from 'react';\n\nimport { Box, Slider, Stack, Typography } from '@mui/material';\n\ntype SliderInputProps = {\n "
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/style-inputs/MultiStylePropertyPanel.tsx",
    "chars": 594,
    "preview": "import React from 'react';\n\nimport { TStyle } from '../../../../../../documents/blocks/helpers/TStyle';\n\nimport SingleSt"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/style-inputs/SingleStylePropertyPanel.tsx",
    "chars": 2336,
    "preview": "import React from 'react';\n\nimport { RoundedCornerOutlined } from '@mui/icons-material';\n\nimport { TStyle } from '../../"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/StylesPanel.tsx",
    "chars": 604,
    "preview": "import React from 'react';\n\nimport { setDocument, useDocument } from '../../documents/editor/EditorContext';\n\nimport Ema"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/ToggleInspectorPanelButton.tsx",
    "chars": 727,
    "preview": "import React from 'react';\n\nimport { AppRegistrationOutlined, LastPageOutlined } from '@mui/icons-material';\nimport { Ic"
  },
  {
    "path": "frontend/email-builder/src/App/InspectorDrawer/index.tsx",
    "chars": 1783,
    "preview": "import React from 'react';\n\nimport {\n  Box, Drawer, Tab, Tabs,\n} from '@mui/material';\n\nimport { setSidebarTab, useInspe"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/DownloadJson/index.tsx",
    "chars": 631,
    "preview": "import React, { useMemo } from 'react';\n\nimport { FileDownloadOutlined } from '@mui/icons-material';\nimport { IconButton"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/HtmlPanel.tsx",
    "chars": 458,
    "preview": "import React, { useMemo } from 'react';\n\nimport { useDocument } from '../../documents/editor/EditorContext';\nimport { re"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/ImportJson/ImportJsonDialog.tsx",
    "chars": 2433,
    "preview": "import React, { useState } from 'react';\n\nimport {\n  Alert,\n  Button,\n  Dialog,\n  DialogActions,\n  DialogContent,\n  Dial"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/ImportJson/index.tsx",
    "chars": 619,
    "preview": "import React, { useState } from 'react';\n\nimport { FileUploadOutlined } from '@mui/icons-material';\nimport { IconButton,"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/ImportJson/validateJsonStringValue.ts",
    "chars": 677,
    "preview": "import { EditorConfigurationSchema, TEditorConfiguration } from '../../../documents/editor/core';\n\ntype TResult = { erro"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/JsonPanel.tsx",
    "chars": 391,
    "preview": "import React, { useMemo } from 'react';\n\nimport { useDocument } from '../../documents/editor/EditorContext';\n\nimport Hig"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/MainTabsGroup.tsx",
    "chars": 1421,
    "preview": "import React from 'react';\n\nimport { CodeOutlined, DataObjectOutlined, EditOutlined, PreviewOutlined } from '@mui/icons-"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/ShareButton.tsx",
    "chars": 1025,
    "preview": "import React, { useState } from 'react';\n\nimport { IosShareOutlined } from '@mui/icons-material';\nimport { IconButton, S"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/helper/HighlightedCodePanel.tsx",
    "chars": 929,
    "preview": "import React, { useEffect, useState } from 'react';\n\nimport { html, json } from './highlighters';\n\ntype TextEditorPanelP"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/helper/highlighters.tsx",
    "chars": 1017,
    "preview": "import hljs from 'highlight.js';\nimport jsonHighlighter from 'highlight.js/lib/languages/json';\nimport xmlHighlighter fr"
  },
  {
    "path": "frontend/email-builder/src/App/TemplatePanel/index.tsx",
    "chars": 3387,
    "preview": "import React from 'react';\n\nimport { MonitorOutlined, PhoneIphoneOutlined } from '@mui/icons-material';\nimport { Box, St"
  },
  {
    "path": "frontend/email-builder/src/App/index.tsx",
    "chars": 2154,
    "preview": "import { Stack, useTheme } from '@mui/material';\nimport React from 'react';\n\nimport { TEditorConfiguration } from '../do"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/ColumnsContainer/ColumnsContainerEditor.tsx",
    "chars": 1774,
    "preview": "import React from 'react';\n\nimport { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-contain"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts",
    "chars": 763,
    "preview": "import { z } from 'zod';\n\nimport { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/b"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/Container/ContainerEditor.tsx",
    "chars": 1122,
    "preview": "import React from 'react';\n\nimport { Container as BaseContainer } from '@usewaypoint/block-container';\n\nimport { useCurr"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/Container/ContainerPropsSchema.tsx",
    "chars": 448,
    "preview": "import { z } from 'zod';\n\nimport { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/EmailLayout/EmailLayoutEditor.tsx",
    "chars": 3508,
    "preview": "import React from 'react';\n\nimport { useCurrentBlockId } from '../../editor/EditorBlock';\nimport { setDocument, setSelec"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/EmailLayout/EmailLayoutPropsSchema.tsx",
    "chars": 782,
    "preview": "import { z } from 'zod';\n\nconst COLOR_SCHEMA = z\n  .string()\n  .regex(/^#[0-9a-fA-F]{6}$/)\n  .nullable()\n  .optional();\n"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlockButton.tsx",
    "chars": 801,
    "preview": "import React from 'react';\n\nimport { Box, Button, SxProps, Typography } from '@mui/material';\n\ntype BlockMenuButtonProps"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlocksMenu.tsx",
    "chars": 1128,
    "preview": "import React from 'react';\n\nimport { Box, Menu } from '@mui/material';\n\nimport { TEditorBlock } from '../../../../editor"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/DividerButton.tsx",
    "chars": 1632,
    "preview": "import React, { useEffect, useState } from 'react';\n\nimport { AddOutlined } from '@mui/icons-material';\nimport { Fade, I"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/PlaceholderButton.tsx",
    "chars": 762,
    "preview": "import React from 'react';\n\nimport { AddOutlined } from '@mui/icons-material';\nimport { ButtonBase } from '@mui/material"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/buttons.tsx",
    "chars": 3639,
    "preview": "import React from 'react';\n\nimport {\n  AccountCircleOutlined,\n  Crop32Outlined,\n  HMobiledataOutlined,\n  HorizontalRuleO"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/index.tsx",
    "chars": 1102,
    "preview": "import React, { useState } from 'react';\n\nimport { TEditorBlock } from '../../../../editor/core';\n\nimport BlocksMenu fro"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/index.tsx",
    "chars": 1509,
    "preview": "import React, { Fragment } from 'react';\n\nimport { TEditorBlock } from '../../../editor/core';\nimport EditorBlock from '"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/TStyle.ts",
    "chars": 260,
    "preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\nexport type TStyle = {\n  backgroundColor?: any;\n  borderColor?:"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/block-wrappers/EditorBlockWrapper.tsx",
    "chars": 1429,
    "preview": "import React, { CSSProperties, useState } from 'react';\n\nimport { Box } from '@mui/material';\n\nimport { useCurrentBlockI"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/block-wrappers/ReaderBlockWrapper.tsx",
    "chars": 664,
    "preview": "import React, { CSSProperties } from 'react';\n\nimport { TStyle } from '../TStyle';\n\ntype TReaderBlockWrapperProps = {\n  "
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/block-wrappers/TuneMenu.tsx",
    "chars": 5215,
    "preview": "import React from 'react';\n\nimport { ArrowDownwardOutlined, ArrowUpwardOutlined, DeleteOutlined } from '@mui/icons-mater"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/fontFamily.ts",
    "chars": 1638,
    "preview": "export const FONT_FAMILIES = [\n  {\n    key: 'MODERN_SANS',\n    label: 'Modern sans',\n    value: '\"Helvetica Neue\", \"Aria"
  },
  {
    "path": "frontend/email-builder/src/documents/blocks/helpers/zod.ts",
    "chars": 525,
    "preview": "import { z } from 'zod';\n\nimport { FONT_FAMILY_NAMES } from './fontFamily';\n\nexport function zColor() {\n  return z.strin"
  },
  {
    "path": "frontend/email-builder/src/documents/editor/EditorBlock.tsx",
    "chars": 788,
    "preview": "import React, { createContext, useContext } from 'react';\n\nimport { EditorBlock as CoreEditorBlock } from './core';\nimpo"
  },
  {
    "path": "frontend/email-builder/src/documents/editor/EditorContext.tsx",
    "chars": 3327,
    "preview": "import { create } from 'zustand';\nimport { subscribeWithSelector } from 'zustand/middleware';\n\nimport getConfiguration f"
  }
]

// ... and 227 more files (download for full content)

About this extraction

This page contains the full source code of the knadh/listmonk GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 427 files (6.8 MB), approximately 1.8M tokens, and a symbol index with 1091 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!