diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml new file mode 100644 index 00000000..c57da6cf --- /dev/null +++ b/.github/workflows/github-pages.yml @@ -0,0 +1,55 @@ +name: publish-github-pages + +on: + push: + branches: + - docs + 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: 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' diff --git a/docs/docs/content/apis/apis.md b/docs/docs/content/apis/apis.md new file mode 100644 index 00000000..27bc90bf --- /dev/null +++ b/docs/docs/content/apis/apis.md @@ -0,0 +1,56 @@ +# APIs + +All features that are available on the listmonk dashboard are also available as REST-like HTTP APIs that can be interacted with directly. Request and response bodies are JSON. This allows easy scripting of listmonk and integration with other systems, for instance, synchronisation with external subscriber databases. + +API requests require BasicAuth authentication with the admin credentials. + +!!! warning "Work in progress" + +> The API section is a work in progress. There are a large number of API calls that are yet to be documented. + +## Response structure + +### Successful request + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "data": {} +} +``` + +All responses from the API server are JSON with the content-type application/json unless explicitly stated otherwise. A successful 200 OK response always has a JSON response body with a status key with the value success. The data key contains the full response payload. + +### Failed request + +```http +HTTP/1.1 500 Server error +Content-Type: application/json + +{ + "message": "Error message" +} +``` + +A failure response is preceded by the corresponding 40x or 50x HTTP header. There may be an optional `data` key with additional payload. + +### Timestamps + +All timestamp fields are in the format `2019-01-01T09:00:00.000000+05:30`. The seconds component is suffixed by the milliseconds, followed by the `+` and the timezone offset. + +### Common HTTP error codes + +| code |   | +| ----- | ------------------------------------------------------------------------ | +| `400` | Missing or bad request parameters or values | +| `403` | Session expired or invalidate. Must relogin | +| `404` | Request resource was not found | +| `405` | Request method (GET, POST etc.) is not allowed on the requested endpoint | +| `410` | The requested resource is gone permanently | +| `429` | Too many requests to the API (rate limiting) | +| `500` | Something unexpected went wrong | +| `502` | The backend OMS is down and the API is unable to communicate with it | +| `503` | Service unavailable; the API is down | +| `504` | Gateway timeout; the API is unreachable | diff --git a/docs/docs/content/apis/campaigns.md b/docs/docs/content/apis/campaigns.md new file mode 100644 index 00000000..a83e4820 --- /dev/null +++ b/docs/docs/content/apis/campaigns.md @@ -0,0 +1,344 @@ +# API / Campaigns + +Method | Endpoint | Description +-------|---------------------------------------------------------------------------|----------------------------- +`GET` | [/api/campaigns](#get-apicampaigns) | Gets all campaigns. +`GET` | [/api/campaigns/:`campaign_id`](#get-apicampaignscampaign_id) | Gets a single campaign. +`GET` | [/api/campaigns/:`campaign_id`/preview](#get-apicampaignscampaign_idpreview) | Gets the HTML preview of a campaign body. +`GET` | [/api/campaigns/running/stats](#get-apicampaignsrunningstats) | Gets the stats of a given set of campaigns. +`POST` | [/api/campaigns](#post-apicampaigns) | Creates a new campaign. +`POST` | /api/campaigns/:`campaign_id`/test | Posts campaign message to arbitrary subscribers for testing. +`PUT` | /api/campaigns/:`campaign_id` | Modifies a campaign. +`PUT` | [/api/campaigns/:`campaign_id`/status](#put-apicampaignscampaign_idstatus) | Start / pause / cancel / schedule a campaign. +`DELETE` | [/api/campaigns/:`campaign_id`](#delete-apicampaignscampaign_id) | Deletes a campaign. + +#### ```GET``` /api/campaigns + +Gets all campaigns. + +##### Example Request + +```shell + curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns?page=1&per_page=100' +``` + +##### Parameters +Name | Type | Required/Optional | Description +--------|--------------------|-------------|---------------------|--------------------- +`query` | string | Optional | Optional string to search a list by name. +`order_by` | string | Optional | Field to sort results by. `name|status|created_at|updated_at` +`order` | string | Optional | `ASC|DESC`Sort by ascending or descending order. +`page` | number | Optional | Page number for paginated results. +`per_page` | number | Optional | Results to return per page. Setting this to `all` skips pagination and returns all results. + + +##### Example Response + +``` json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2020-03-14T17:36:41.29451+01:00", + "updated_at": "2020-03-14T17:36:41.29451+01:00", + "CampaignID": 0, + "views": 0, + "clicks": 0, + "lists": [ + { + "id": 1, + "name": "Default list" + } + ], + "started_at": null, + "to_send": 0, + "sent": 0, + "uuid": "57702beb-6fae-4355-a324-c2fd5b59a549", + "type": "regular", + "name": "Test campaign", + "subject": "Welcome to listmonk", + "from_email": "No Reply ", + "body": "

Hi {{ .Subscriber.FirstName }}!

\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.", + "send_at": "2020-03-15T17:36:41.293233+01:00", + "status": "draft", + "content_type": "richtext", + "tags": [ + "test-campaign" + ], + "template_id": 1, + "messenger": "email" + } + ], + "query": "", + "total": 1, + "per_page": 20, + "page": 1 + } +} +``` + +#### ```GET``` /api/campaigns/:`campaign_id` + +Gets a single campaign. + +##### Parameters +Name | Parameter Type | Data Type | Required/Optional | Description +------------|-------------------|--------------|----------------------|----------------------------- +`campaign_id` | Path Parameter | Number | Required | The id value of the campaign you want to get. + + +##### Example Request + +``` shell +curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/1' +``` + +##### Example Response + +``` json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.29451+01:00", + "updated_at": "2020-03-14T17:36:41.29451+01:00", + "CampaignID": 0, + "views": 0, + "clicks": 0, + "lists": [ + { + "id": 1, + "name": "Default list" + } + ], + "started_at": null, + "to_send": 0, + "sent": 0, + "uuid": "57702beb-6fae-4355-a324-c2fd5b59a549", + "type": "regular", + "name": "Test campaign", + "subject": "Welcome to listmonk", + "from_email": "No Reply ", + "body": "

Hi {{ .Subscriber.FirstName }}!

\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.", + "send_at": "2020-03-15T17:36:41.293233+01:00", + "status": "draft", + "content_type": "richtext", + "tags": [ + "test-campaign" + ], + "template_id": 1, + "messenger": "email" + } +} +``` + + + + + +#### ```GET``` /api/campaigns/:`campaign_id`/preview + +Gets the html preview of a campaign body. + +##### Parameters + +Name | Parameter Type | Data Type | Required/Optional | Description +------------|-------------------|--------------|----------------------|----------------------------- +`campaign_id` | Path Parameter | Number | Required | The id value of the campaign to be previewed. + + +##### Example Request + +```shell +curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/1/preview' +``` + +##### Example Response + +``` html +

Hi John!

+This is a test e-mail campaign. Your second name is Doe and you are from Bengaluru. +``` + +#### ```GET``` /api/campaigns/running/stats + +Gets the running stat of a given set of campaigns. + +##### Parameters + +Name | Parameter Type | Data Type | Required/Optional | Description +------------|------------------|------------|---------------------|-------------------------------- +campaign_id | Query Parameters | Number | Required | The id values of the campaigns whose stat you want to get. + + +##### Example Request + +``` shell +curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/running/stats?campaign_id=1' +``` + +##### Example Response + +``` json +{ + "data": [] +} +``` + + + + + +### ```POST ``` /api/campaigns + +Creates a new campaign. + +#### Parameters +| Name | Data type | Required/Optional | Description | +|----------------|-----------|-------------------|--------------------------------------------------------------------------------------------------------| +| `name` | String | Required | Name of the campaign. | +| `subject` | String | Required | (E-mail) subject of the campaign. | +| `lists` | []Number | Required | Array of list IDs to send the campaign to. | +| `from_email` | String | Optional | `From` e-mail to show on the campaign e-mails. If left empty, the default value from settings is used. | +| `type` | String | Required | `regular` or `optin` campaign. | +| `content_type` | String | Required | `richtext`, `html`, `markdown`, `plain` | +| `body` | String | Required | Campaign content body. | +| `altbody` | String | Optional | Alternate plain text body for HTML (and richtext) e-mails. | +| `send_at` | String | Optional | A timestamp to schedule the campaign at. Eg: `2021-12-25T06:00:00` (YYYY-MM-DDTHH:MM:SS) | +| `messenger` | String | Optional | `email` or a custom messenger defined in the settings. If left empty, `email` is used. | +| `template_id` | Number | Optional | ID of the template to use. If left empty, the default template is used. | +| `tags` | []String | Optional | Array of string tags to mark the campaign. | + + + + +#### Example request + +```shell +curl -u "username:password" 'http://localhost:9000/api/campaigns' -X POST -H 'Content-Type: application/json;charset=utf-8' --data-raw '{"name":"Test campaign","subject":"Hello, world","lists":[1],"from_email":"listmonk ","content_type":"richtext","messenger":"email","type":"regular","tags":["test"],"template_id":1}' +``` + +#### Example response +```json +{ + "data": { + "id": 1, + "created_at": "2021-12-27T11:50:23.333485Z", + "updated_at": "2021-12-27T11:50:23.333485Z", + "views": 0, + "clicks": 0, + "bounces": 0, + "lists": [{ + "id": 1, + "name": "Default list" + }], + "started_at": null, + "to_send": 1, + "sent": 0, + "uuid": "90c889cc-3728-4064-bbcb-5c1c446633b3", + "type": "regular", + "name": "Test campaign", + "subject": "Hello, world", + "from_email": "listmonk \u003cnoreply@listmonk.yoursite.com\u003e", + "body": "", + "altbody": null, + "send_at": null, + "status": "draft", + "content_type": "richtext", + "tags": ["test"], + "template_id": 1, + "messenger": "email" + } +} +``` + + +#### ```PUT``` /api/campaigns/:`campaign_id`/status + +Modifies a campaign status to start, pause, cancel, or schedule a campaign. + +##### Parameters + +Name | Parameter Type | Data Type | Required/Optional | Description +------------------|-------------------------|---------------------------|----------------------|----------------------------- +`campaign_id` | Path Parameter | Number | Required | The id value of the campaign whose status is to be modified. +`status` | Request Body | String | Required | `scheduled`, `running`, `paused`, `cancelled`. + + +###### Note: + > * Only "scheduled" campaigns can be saved as "draft". + * Only "draft" campaigns can be "scheduled". + * Only "paused" campaigns and "draft" campaigns can be started. + * Only "running" campaigns can be "cancelled" and "paused". + + +##### Example Request + +```shell +curl -u "username:password" -X PUT 'http://localhost:9000/api/campaigns/1/status' \ +--header 'Content-Type: application/json' \ +--data-raw '{"status":"scheduled"}' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.29451+01:00", + "updated_at": "2020-04-08T19:35:17.331867+01:00", + "CampaignID": 0, + "views": 0, + "clicks": 0, + "lists": [ + { + "id": 1, + "name": "Default list" + } + ], + "started_at": null, + "to_send": 0, + "sent": 0, + "uuid": "57702beb-6fae-4355-a324-c2fd5b59a549", + "type": "regular", + "name": "Test campaign", + "subject": "Welcome to listmonk", + "from_email": "No Reply ", + "body": "

Hi {{ .Subscriber.FirstName }}!

\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.", + "send_at": "2020-03-15T17:36:41.293233+01:00", + "status": "scheduled", + "content_type": "richtext", + "tags": [ + "test-campaign" + ], + "template_id": 1, + "messenger": "email" + } +} +``` + +#### ```DELETE``` /api/campaigns/:`campaign_id` + +Deletes a campaign, only scheduled campaigns that have not yet been started can be deleted. + +##### Parameters + +Name | Parameter Type | Data Type | Required/Optional | Description +---------|--------------------|----------------|---------------------|------------------------------ +`campaign_id`| Path Parameter | Number | Required | The id value of the campaign you want to delete. + + +##### Example Request + +```shell +curl -u "username:password" -X DELETE 'http://localhost:9000/api/campaigns/34' +``` + +##### Example Response + +```json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/import.md b/docs/docs/content/apis/import.md new file mode 100644 index 00000000..11bc5f8b --- /dev/null +++ b/docs/docs/content/apis/import.md @@ -0,0 +1,89 @@ +# API / Import + +Method | Endpoint | Description +-----------|----------------------------------------------------------------------|-------------- +`GET` | [api/import/subscribers](#get-apiimportsubscribers) | Gets a import statistics. +`GET` | [api/import/subscribers/logs](#get-apiimportsubscriberslogs) | Get a import statistics . +`POST` | [api/import/subscribers](#post-apiimportsubscribers) | Upload a ZIP file or CSV file to bulk import subscribers. +`DELETE` | [api/import/subscribers](#delete-apiimportsubscribers) | Stops and deletes a import. + + +#### **`GET`** api/import/subscribers +Gets import status. + +##### Example Request +```shell +curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers' +``` + +##### Example Response +```json +{ + "data": { + "name": "", + "total": 0, + "imported": 0, + "status": "none" + } +} +``` + +#### **`GET`** api/import/subscribers/logs +Gets import logs. + +##### Example Request +```shell +curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers/logs' +``` + +##### Example Response +```json +{ + "data": "2020/04/08 21:55:20 processing 'import.csv'\n2020/04/08 21:55:21 imported finished\n" +} +``` + + + +#### **`POST`** api/import/subscribers +Post a CSV (optionally zipped) file to do a bulk import. The request should be a multipart form POST. + + +##### Parameters + +Name | Parameter type | Data type | Required/Optional | Description +---------|----------------|----------------|-------------------|----------------------- +`params` | Request body | String | Required | Stringified JSON with import params +`file` | Request body | File | Required | File to upload + +***params*** (JSON string) + +```json + { + "mode": "subscribe", // subscribe or blocklist + "delim": ",", // delimiter in the uploaded file + "lists":[1], // array of list IDs to import into + "overwrite": true // overwrite existing entries or skip them? + } +``` + + +#### **`DELETE`** api/import/subscribers +Stops and deletes an import. + +##### Example Request +```shell +curl -u "username:username" -X DELETE 'http://localhost:9000/api/import/subscribers' +``` + +##### Example Response +```json +{ + "data": { + "name": "", + "total": 0, + "imported": 0, + "status": "none" + } +} +``` \ No newline at end of file diff --git a/docs/docs/content/apis/lists.md b/docs/docs/content/apis/lists.md new file mode 100644 index 00000000..750a6914 --- /dev/null +++ b/docs/docs/content/apis/lists.md @@ -0,0 +1,162 @@ +# API / Lists +Method | Endpoint | Description +------------|------------------------------------------------------|---------------------------------------------- +`GET` | [/api/lists](#get-apilists) | Gets all lists. +`GET` | [/api/lists/:`list_id`](#get-apilistslist_id) | Gets a single list. +`POST` | [/api/lists](#post-apilists) | Creates a new list. +`PUT` | /api/lists/:`list_id` | Modifies a list. +`DELETE` | [/api/lists/:`list_id`](#put-apilistslist_id) | Deletes a list. + + +#### **`GET`** /api/lists +Gets lists. + +##### Parameters +Name | Type | Required/Optional | Description +-----------|--------|--------------------|----------------------------------------- +`query` | string | Optional | Optional string to search a list by name. +`order_by` | string | Optional | Field to sort results by. `name|status|created_at|updated_at` +`order` | string | Optional | `ASC|DESC`Sort by ascending or descending order. +`page` | number | Optional | Page number for paginated results. +`per_page` | number | Optional | Results to return per page. Setting this to `all` skips pagination and returns all results. + +##### Example Request +```shell +curl -u "username:username" -X GET 'http://localhost:9000/api/lists?page=1&per_page=100' +``` + +##### Example Response +```json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-03-06T22:32:01.118327+01:00", + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "optin": "double", + "tags": [ + "test" + ], + "subscriber_count": 2 + }, + { + "id": 2, + "created_at": "2020-03-04T21:12:09.555013+01:00", + "updated_at": "2020-03-06T22:34:46.405031+01:00", + "uuid": "f20a2308-dfb5-4420-a56d-ecf0618a102d", + "name": "get", + "type": "private", + "optin": "single", + "tags": [], + "subscriber_count": 0 + } + ], + "total": 5, + "per_page": 20, + "page": 1 + } +} +``` + +#### **`GET`** /api/lists/:`list_id` +Gets a single list. + +##### Parameters +Name | Parameter type | Data type | Required/Optional | Description +----------|--------------------|-------------|---------------------|--------------------- +`list_id` | Path parameter | number | Required | The id value of the list you want to get. + +##### Example Request +``` shell +curl -u "username:username" -X GET 'http://localhost:9000/api/lists/5' +``` + +##### Example Response +```json +{ + "data": { + "id": 5, + "created_at": "2020-03-07T06:31:06.072483+01:00", + "updated_at": "2020-03-07T06:31:06.072483+01:00", + "uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a", + "name": "Test list", + "type": "public", + "optin": "double", + "tags": [], + "subscriber_count": 0 + } +} +``` + +#### **`POST`** /api/lists +Creates a new list. + +##### Parameters +Name | Parameter type | Data type | Required/Optional | Description +--------|-----------------|-------------|--------------------|---------------- +name | Request body | string | Required | The new list name. +type | Request body | string | Required | List type, can be set to `private` or `public`. +optin | Request body | string | Required | `single` or `double` optin. +tags | Request body | string[] | Optional | The tags associated with the list. + +##### Example Request +``` shell +curl -u "username:username" -X POST 'http://localhost:9000/api/lists' +``` + +##### Example Response +```json +{ + "data": { + "id": 5, + "created_at": "2020-03-07T06:31:06.072483+01:00", + "updated_at": "2020-03-07T06:31:06.072483+01:00", + "uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a", + "name": "Test list", + "type": "public", + "tags": [], + "subscriber_count": 0 + } +} +null +``` + +#### **`PUT`** /api/lists/`list_id` +Modifies a list. + +##### Parameters +Name | Parameter type | Data type | Required/Optional | Description +----------|--------------------|--------------|-----------------------|------------------------- +`list_id` | Path parameter | number | Required | The id of the list to be modified. +name | Request body | string | Optional | The name which the old name will be modified to. +type | Request body | string | Optional | List type, can be set to `private` or `public`. +optin | Request body | string | Optional | `single` or `double` optin. +tags | Request body | string[] | Optional | The tags associated with the list. + +##### Example Request +```shell +curl -u "username:username" -X PUT 'http://localhost:9000/api/lists/5' \ +--form 'name=modified test list' \ +--form 'type=private' +``` + +##### Example Response +``` json +{ + "data": { + "id": 5, + "created_at": "2020-03-07T06:31:06.072483+01:00", + "updated_at": "2020-03-07T06:52:15.208075+01:00", + "uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a", + "name": "modified test list", + "type": "private", + "optin": "single", + "tags": [], + "subscriber_count": 0 + } +} +``` diff --git a/docs/docs/content/apis/media.md b/docs/docs/content/apis/media.md new file mode 100644 index 00000000..b9c3440c --- /dev/null +++ b/docs/docs/content/apis/media.md @@ -0,0 +1,107 @@ +# API / Media +Method | Endpoint | Description +--------------|--------------------------------------------------------------|---------------------------------------------- +`GET` | [/api/media](#get-apimedia) | Gets an uploaded media file. +`POST` | [/api/media](#post-apimedia) | Uploads a media file. +`DELETE` | [/api/media/:`media_id`](#delete-apimediamedia_id) | Deletes uploaded media files. + +#### **`GET`** /api/media +Gets an uploaded media file. + +##### Example Request +```shell +curl -u "username:username" -X GET 'http://localhost:9000/api/media' \ +--header 'Content-Type: multipart/form-data; boundary=--------------------------093715978792575906250298' +``` + +##### Example Response +```json +{ + "data": [ + { + "id": 1, + "uuid": "ec7b45ce-1408-4e5c-924e-965326a20287", + "filename": "Media file", + "created_at": "2020-04-08T22:43:45.080058+01:00", + "thumb_uri": "/uploads/image_thumb.jpg", + "uri": "/uploads/image.jpg" + } + ] +} +``` + +Response definitions +The following table describes each item in the response. + +|Response item |Description |Data type | +|:---------------:|:------------|:----------:| +|data|Array of the media file objects, which contains an information about the uploaded media files|array| +|id|Media file object ID|number (int)| +|uuid|Media file uuuid|string (uuid)| +|filename|Name of the media file|string| +|created_at|Date and time, when the media file object was created|String (localDateTime)| +|thumb_uri|The thumbnail URI of the media file|string| +|uri|URI of the media file|string| + +#### **`POST`** /api/media +Uploads a media file. + +##### Parameters +Nam | Parameter Type | Data Type | Required/Optional | Description +-----------|-----------------------|-------------------|-------------------------|--------------------------------- +file | Request body | Media file | Required | The media file to be uploaded. + + +##### Example Request +```shell +curl -u "username:username" -X POST 'http://localhost:9000/api/media' \ +--header 'Content-Type: multipart/form-data; boundary=--------------------------183679989870526937212428' \ +--form 'file=@/path/to/image.jpg' +``` + +##### Example Response +``` json +{ + "data": { + "id": 1, + "uuid": "ec7b45ce-1408-4e5c-924e-965326a20287", + "filename": "Media file", + "created_at": "2020-04-08T22:43:45.080058+01:00", + "thumb_uri": "/uploads/image_thumb.jpg", + "uri": "/uploads/image.jpg" + } +} +``` +Response definitions + +|Response item |Description |Data type | +|:---------------:|:------------:|:----------:| +|data|True means that the media file was successfully uploaded |boolean| + +#### **`DELETE`** /api/media/:`media_id` +Deletes an uploaded media file. + +##### Parameters +Name | Parameter Type | Data Type | Required/Optional | Description +----------------|-------------------------|--------------------|-------------------------|-------------------------- +`Media_id` | Path Parameter | Number | Required | The id of the media file you want to delete. + + +##### Example Request +```shell +curl -u "username:username" -X DELETE 'http://localhost:9000/api/media/1' +``` + + +##### Example Response +```json +{ + "data": true +} +``` + +Response definitions + +|Response item |Description |Data type | +|:---------------:|:------------:|:----------:| +|data|True means that the media file was successfully deleted |boolean| diff --git a/docs/docs/content/apis/subscribers.md b/docs/docs/content/apis/subscribers.md new file mode 100644 index 00000000..e7f29e09 --- /dev/null +++ b/docs/docs/content/apis/subscribers.md @@ -0,0 +1,461 @@ +# API / Subscribers + +Method | Endpoint | Description +---------|-----------------------------------------------------------------------|----------------------------------------------------------- +`GET` | [/api/subscribers](#get-apisubscribers) | Gets all subscribers. +`GET` | [/api/subscribers/:`id`](#get-apisubscribersid) | Gets a single subscriber. +`GET` | /api/subscribers/lists/:`id` | Gets subscribers in a list. +`GET` | [/api/subscribers](#get-apisubscriberslist_id) | Gets subscribers in one or more lists. +`GET` | [/api/subscribers](#get-apisubscribers_1) | Gets subscribers filtered by an arbitrary SQL expression. +`POST` | [/api/subscribers](#post-apisubscribers) | Creates a new subscriber. +`PUT` | [/api/subscribers/lists](#put-apisubscriberslists) | Modify subscribers' list memberships. +`PUT` | /api/subscribers/:`id` | Updates a subscriber by ID. +`PUT` | [/api/subscribers/:`id`/blocklist](#put-apisubscribersidblocklist) | Blocklists a single subscriber. +`PUT` | /api/subscribers/blocklist | Blocklists one or more subscribers. +`PUT` | [/api/subscribers/query/blocklist](#put-apisubscribersqueryblocklist) | Blocklists subscribers with an arbitrary SQL expression. +`DELETE` | [/api/subscribers/:`id`](#delete-apisubscribersid) | Deletes a single subscriber. +`DELETE` | [/api/subscribers](#delete-apisubscribers) | Deletes one or more subscribers . +`POST` | [/api/subscribers/query/delete](#post-apisubscribersquerydelete) | Deletes subscribers with an arbitrary SQL expression. + + +#### **`GET`** /api/subscribers +Gets all subscribers. + +##### Example Request +```shell +curl 'http://localhost:9000/api/subscribers?page=1&per_page=100' +``` + +To skip pagination and retrieve all records, pass `per_page=all`. + +##### Example Response +```json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2020-02-10T23:07:16.199433+01:00", + "updated_at": "2020-02-10T23:07:16.199433+01:00", + "uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c", + "email": "john@example.com", + "name": "John Doe", + "attribs": { + "city": "Bengaluru", + "good": true, + "type": "known" + }, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "tags": [ + "test" + ], + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-02-10T23:07:16.194843+01:00" + } + ] + }, + { + "id": 2, + "created_at": "2020-02-18T21:10:17.218979+01:00", + "updated_at": "2020-02-18T21:10:17.218979+01:00", + "uuid": "ccf66172-f87f-4509-b7af-e8716f739860", + "email": "quadri@example.com", + "name": "quadri", + "attribs": {}, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "tags": [ + "test" + ], + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-02-10T23:07:16.194843+01:00" + } + ] + }, + { + "id": 3, + "created_at": "2020-02-19T19:10:49.36636+01:00", + "updated_at": "2020-02-19T19:10:49.36636+01:00", + "uuid": "5d940585-3cc8-4add-b9c5-76efba3c6edd", + "email": "sugar@example.com", + "name": "sugar", + "attribs": {}, + "status": "enabled", + "lists": [] + } + ], + "query": "", + "total": 3, + "per_page": 20, + "page": 1 + } +} +``` + + +#### **`GET`** /api/subscribers/:`id` + Gets a single subscriber. + +##### Parameters + +Name | Parameter type |Data type | Required/Optional | Description +---------|----------------|----------------|-------------------|----------------------- +`id` | Path parameter | Number | Required | The id value of the subscriber you want to get. + +##### Example Request +```shell +curl 'http://localhost:9000/api/subscribers/1' +``` + +##### Example Response +```json +{ + "data": { + "id": 1, + "created_at": "2020-02-10T23:07:16.199433+01:00", + "updated_at": "2020-02-10T23:07:16.199433+01:00", + "uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c", + "email": "john@example.com", + "name": "John Doe", + "attribs": { + "city": "Bengaluru", + "good": true, + "type": "known" + }, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "tags": [ + "test" + ], + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-02-10T23:07:16.194843+01:00" + } + ] + } +} +``` + + + +#### **`GET`** /api/subscribers +Gets subscribers in one or more lists. + +##### Parameters + +Name | Parameter type | Data type | Required/Optional | Description +----------|-----------------|-------------|---------------------|--------------- +`List_id` | Request body | Number | Required | ID of the list to fetch subscribers from. + +##### Example Request +```shell +curl 'http://localhost:9000/api/subscribers?list_id=1&list_id=2&page=1&per_page=100' +``` + +To skip pagination and retrieve all records, pass `per_page=all`. + +##### Example Response + +```json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2019-06-26T16:51:54.37065+05:30", + "updated_at": "2019-07-03T11:53:53.839692+05:30", + "uuid": "5e91dda1-1c16-467d-9bf9-2a21bf22ae21", + "email": "test@test.com", + "name": "Test Subscriber", + "attribs": { + "city": "Bengaluru", + "projects": 3, + "stack": { + "languages": ["go", "python"] + } + }, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "41badaf2-7905-4116-8eac-e8817c6613e4", + "name": "Default list", + "type": "public", + "tags": ["test"], + "created_at": "2019-06-26T16:51:54.367719+05:30", + "updated_at": "2019-06-26T16:51:54.367719+05:30" + } + ] + } + ], + "query": "", + "total": 1, + "per_page": 20, + "page": 1 + } +} +``` + +#### **`GET`** /api/subscribers +Gets subscribers with an SQL expression. + +##### Example Request +```shell +curl -X GET 'http://localhost:9000/api/subscribers' \ + --url-query 'page=1' \ + --url-query 'per_page=100' \ + --url-query "query=subscribers.name LIKE 'Test%' AND subscribers.attribs->>'city' = 'Bengaluru'" +``` + +To skip pagination and retrieve all records, pass `per_page=all`. + + +>Refer to the [querying and segmentation](/docs/querying-and-segmentation#querying-and-segmenting-subscribers) section for more information on how to query subscribers with SQL expressions. + +##### Example Response +```json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2019-06-26T16:51:54.37065+05:30", + "updated_at": "2019-07-03T11:53:53.839692+05:30", + "uuid": "5e91dda1-1c16-467d-9bf9-2a21bf22ae21", + "email": "test@test.com", + "name": "Test Subscriber", + "attribs": { + "city": "Bengaluru", + "projects": 3, + "stack": { + "frameworks": ["echo", "go"], + "languages": ["go", "python"] + } + }, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "41badaf2-7905-4116-8eac-e8817c6613e4", + "name": "Default list", + "type": "public", + "tags": ["test"], + "created_at": "2019-06-26T16:51:54.367719+05:30", + "updated_at": "2019-06-26T16:51:54.367719+05:30" + } + ] + } + ], + "query": "subscribers.name LIKE 'Test%' AND subscribers.attribs-\u003e\u003e'city' = 'Bengaluru'", + "total": 1, + "per_page": 20, + "page": 1 + } +} +``` + + +#### **`POST`** /api/subscribers + +Creates a new subscriber. + +##### Parameters + +Name | Parameter type | Data type | Required/Optional | Description +-------------------------|------------------|------------|-------------------|---------------------------- +email | Request body | String | Required | The email address of the new susbcriber. +name | Request body | String | Required | The name of the new subscriber. +status | Request body | String | Required | The status of the new subscriber. Can be enabled, disabled or blocklisted. +lists | Request body | Numbers | Optional | Array of list IDs to subscribe to (marked as `unconfirmed` by default). +attribs | Request body | json | Optional | JSON list containing new subscriber's attributes. +preconfirm_subscriptions | Request body | Bool | Optional | If `true`, marks subscriptsions as `confirmed` and no-optin e-mails are sent for double opt-in lists. + +##### Example Request +```shell +curl 'http://localhost:9000/api/subscribers' -H 'Content-Type: application/json' \ + --data '{"email":"subsriber@domain.com","name":"The Subscriber","status":"enabled","lists":[1],"attribs":{"city":"Bengaluru","projects":3,"stack":{"languages":["go","python"]}}}' +``` + +##### Example Response + +```json +{ + "data": { + "id": 3, + "created_at": "2019-07-03T12:17:29.735507+05:30", + "updated_at": "2019-07-03T12:17:29.735507+05:30", + "uuid": "eb420c55-4cfb-4972-92ba-c93c34ba475d", + "email": "subsriber@domain.com", + "name": "The Subscriber", + "attribs": { + "city": "Bengaluru", + "projects": 3, + "stack": { "languages": ["go", "python"] } + }, + "status": "enabled", + "lists": [1] + } +} +``` + + +#### **`PUT`** /api/subscribers/lists + +Modify subscribers list memberships. + +##### Parameters + +Name | Paramter type | Data type | Required/Optional | Description +------------------|---------------|-----------|--------------------|------------------------------------------------------- +`ids` | Request body | Numbers | Required | The ids of the subscribers to be modified. +`action` | Request body | String | Required | Wether to `add`, `remove`, or `unsubscribe` the users. +`target_list_ids` | Request body | Numbers | Required | The ids of the lists to be modified. +`status` | Request body | String | Required for `add` | `confirmed`, `unconfirmed`, or `unsubscribed` status. + +##### Example Request + +To subscribe users 1, 2, and 3 to lists 4, 5, and 6: + +```shell +curl -u "username:username" -X PUT 'http://localhost:9000/api/subscribers/lists' \ +--data-raw '{"ids": [1, 2, 3], "action": "add", "target_list_ids": [4, 5, 6], "status": "confirmed"}' +``` + +##### Example Response + +``` json +{ + "data": true +} +``` + + +#### **`PUT`** /api/subscribers/:`id`/blocklist +Blocklists a single subscriber. + +##### Parameters + +Name | Parameter type | Data type | Required/Optional | Description +------|----------------|------------|-------------------|------------- +`id` | Path parameter | Number | Required | The id value of the subscriber you want to blocklist. + +##### Example Request + +```shell +curl -u "username:username" -X PUT 'http://localhost:9000/api/subscribers/9/blocklist' +``` + +##### Example Response + +``` json +{ + "data": true +} +``` + +#### **`PUT`** /api/subscribers/query/blocklist +Blocklists subscribers with an arbitrary sql expression. + +##### Example Request +``` shell +curl -u "username:username" -X PUT 'http://localhost:9000/api/subscribers/query/blocklist' \ +--data-raw '"query=subscribers.name LIKE '\''John Doe'\'' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"' +``` + +>Refer to the [querying and segmentation](/querying-and-segmentation#querying-and-segmenting-subscribers) section for more information on how to query subscribers with SQL expressions. + + +##### Example Response + +``` json +{ + "data": true +} +``` + +#### **`DELETE`** /api/subscribers/:`id` +Deletes a single subscriber. + +##### Parameters + +Name | Parameter type | Data type | Required/Optional | Description +--------|------------------|-------------|--------------------|------------------ +`id` | Path parameter | Number | Required | The id of the subscriber you want to delete. + +##### Example Request + +``` shell +curl -u "username:username" -X DELETE 'http://localhost:9000/api/subscribers/9' +``` + +##### Example Response + +``` json +{ + "data": true +} +``` + +#### **`DELETE`** /api/subscribers +Deletes one or more subscribers. + +##### Parameters + +Name | Parameter type | Data type | Required/Optional | Description +--------|---------------------|----------------|-----------------------|-------------- +id | Query parameters | Number | Required | The id of the subscribers you want to delete. + +##### Example Request + +``` shell +curl -u "username:username" -X DELETE 'http://localhost:9000/api/subscribers?id=10&id=11' +``` + +##### Example Response + +``` json +{ + "data": true +} +``` + + + +#### **`POST`** /api/subscribers/query/delete +Deletes subscribers with an arbitrary SQL expression. + +##### Example Request +``` shell +curl -u "username:username" -X POST 'http://localhost:9000/api/subscribers/query/delete' \ +--data-raw '"query=subscribers.name LIKE '\''John Doe'\'' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"' +``` + +>Refer to the [querying and segmentation](/querying-and-segmentation#querying-and-segmenting-subscribers) section for more information on how to query subscribers with SQL expressions. + + +##### Example Response +``` json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/templates.md b/docs/docs/content/apis/templates.md new file mode 100644 index 00000000..920eae7c --- /dev/null +++ b/docs/docs/content/apis/templates.md @@ -0,0 +1,145 @@ +# API / Templates + +Method | Endpoint | Description +---------------------|-----------------------------------------|----------------------------------------------------- +`GET` | [/api/templates](#get-apitemplates) | Gets all templates. +`GET` | [/api/templates/:`template_id`](#get-apitemplatestemplate_id) | Gets a single template. +`GET` | [/api/templates/:`template_id`/preview](#get-apitemplatestemplate_idpreview) | Gets the HTML preview of a template. +`POST` | /api/templates/preview | +`POST` | /api/templates | Creates a template. +`PUT` | /api/templates/:`template_id` | Modifies a template. +`PUT` | [/api/templates/:`template_id`/default](#put-apitemplatestemplate_iddefault) | Sets a template to the default template. +`DELETE` | [/api/templates/:`template_id`](#delete-apitemplatestemplate_id) | Deletes a template. + +#### **`GET`** /api/templates +Gets all templates. + +##### Example Request + ```shell + curl -u "username:username" -X GET 'http://localhost:9000/api/templates' + ``` + +##### Example Response +``` json +{ + "data": [ + { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } + ] +} +``` + +#### **`GET`** /api/templates/:`template_id` +Gets a single template. + +##### Parameters +Name | Parameter Type | Data Type | Required/Optional | Description +------------|------------------------|---------------------|-------------------------|------------------------------------------ +`template_id` | Path Parameter | Number | Required | The id value of the template you want to get. + +##### Example Request +``` shell +curl -u "username:username" -X GET 'http://localhost:9000/api/templates/1' +``` + +##### Example Response +```json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } +} +``` + +#### **`GET`** /api/templates/:`template_id`/preview +Gets the HTML preview of a template body. + +##### Parameters +Name | Parameter Type | Data Type | Required/Optional | Description +------------|----------------------|-----------------|------------------------|--------------------------------- +`template_id` | Path Parameter | Number | Required | The id value of the template whose html preview you want to get. + +##### Example Request +``` shell +curl -u "username:username" -X GET 'http://localhost:9000/api/templates/1/preview' +``` + +##### Example Response +``` html +

Hi there

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. + Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. + Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. + Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed + erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.

+ +

Sub heading

+

Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. + Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.

+ +

Here is a link to listmonk.

+``` + +#### **`PUT`** /api/templates/:`template_id`/default +Sets a template to the default template. + +##### Parameters +Name | Parameter Type | Data Type | Required/Optional | Description +------------|------------------------|---------------------|-------------------------|------------------------------------------ +`template_id` | Path Parameter | Number | Required | The id value of the template you want to set to the default template. + + +##### Example Request +``` shell +curl -u "username:username" -X PUT 'http://localhost:9000/api/templates/1/default' +``` + +##### Example Response +``` json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } +} +``` + + +#### **`DELETE`** /api/templates/:`template_id` +Deletes a template. + +##### Parameters +Name | Parameter Type | Data Type | Required/Optional | Description +------------|------------------------|---------------------|-------------------------|------------------------------------------ +`template_id` | Path Parameter | Number | Required | The id value of the template you want to delete. + + +##### Example Request +``` shell +curl -u "username:username" -X DELETE 'http://localhost:9000/api/templates/35' +``` + +##### Example Response +```json +{ + "data": true +} +``` \ No newline at end of file diff --git a/docs/docs/content/apis/transactional.md b/docs/docs/content/apis/transactional.md new file mode 100644 index 00000000..4e675579 --- /dev/null +++ b/docs/docs/content/apis/transactional.md @@ -0,0 +1,46 @@ +# API / Transactional + +| Method | Endpoint | Description | +|:-------|:---------|:------------| +| `POST` | /api/tx | | + + +## POST /api/tx +Send a transactional message to a subscriber using a predefined transactional template. + + +##### Parameters +| Name | Data Type | Optional | Description | +|:-------------------|:----------|:---------|:----------------------------------------------------------------------------------| +| `subscriber_email` | String | Optional | E-mail of the subscriber. Either this or `subscriber_id` should be passed. | +| `subscriber_id` | Number | Optional | ID of the subscriber. Either this or `subscriber_email` should be passed. | +| `template_id` | Number | Required | ID of the transactional template to use in the message. | +| `from_email` | String | Optional | Optional `from` email. eg: `Company ` | +| `data` | Map | Optional | Optional data in `{}` nested map. Available in the template as `{{ .Tx.Data.* }}` | +| `headers` | []Map | Optional | Optional array of mail headers. `[{"key": "value"}, {"key": "value"}]` | +| `messenger` | String | Optional | Messenger to use to send the message. Default value is `email`. | +| `content_type` | String | Optional | `html`, `markdown`, `plain` | + + +##### Request +```shell +curl -u "username:password" "http://localhost:9000/api/tx" -X POST \ + -H 'Content-Type: application/json; charset=utf-8' \ + --data-binary @- << EOF + { + "subscriber_email": "user@test.com", + "template_id": 2, + "data": {"order_id": "1234", "date": "2022-07-30", "items": [1, 2, 3]}, + "content_type": "html" + } +EOF +``` + +##### Response +``` json +{ + "data": true +} +``` + + diff --git a/docs/docs/content/bounces.md b/docs/docs/content/bounces.md new file mode 100644 index 00000000..f17160fe --- /dev/null +++ b/docs/docs/content/bounces.md @@ -0,0 +1,48 @@ +# Bounce processing + +Enable bounce processing in Settings -> Bounces. POP3 bounce scanning and APIs only become available once the setting is enabled. + +### POP3 bounce mailbox +Configure the bounce mailbox in Settings -> Bounces. Either the "From" e-mail that is set on a campaign (or in settings) should have a POP3 mailbox behind it to receive bounce e-mails, or you should configure a dedicated POP3 mailbox and add that address as the `Return-Path` (envelope sender) header in Settings -> SMTP -> Custom headers box. For example: + +``` +[ + {"Return-Path": "your-bounce-inbox@site.com"} +] + +``` + +Some mail servers may also return the bounce to the `Reply-To` address, which can also be added to the header settings. + +### Webhook API +The bounce webhook API can be used to record bounce events with custom scripting. This could be by reading a mailbox, a database, or mail server logs. + +| Method | Endpoint | Description | +|--------|------------------|------------------------| +| `POST` | /webhooks/bounce | Record a bounce event. | + + +| Name | Data type | Required/Optional | Description | +|-------------------|-----------|-------------------|--------------------------------------------------------------------------------------| +| `subscriber_uuid` | String | Optional | The UUID of the subscriber. Either this or `email` is required. | +| `email` | String | Optional | The e-mail of the subscriber. Either this or `subscriber_uuid` is required. | +| `campaign_uuid` | String | Optional | UUID of the campaign for which the bounce happened. | +| `source` | String | Required | A string indicating the source, eg: `api`, `my_script` etc. | +| `type` | String | Required | `hard` or `soft` bounce. Currently, this has no effect on how the bounce is treated. | +| `meta` | String | Optional | An optional escaped JSON string with arbitrary metadata about the bounce event. | + + +```shell +curl -u 'username:password' -X POST localhost:9000/webhooks/bounce \ + -H "Content-Type: application/json" \ + --data '{"email": "user1@mail.com", "campaign_uuid": "9f86b50d-5711-41c8-ab03-bc91c43d711b", "source": "api", "type": "hard", "meta": "{\"additional\": \"info\"}}' + +``` + +### External webhooks +listmonk supports receiving bounce webhook events from the following SMTP providers. + +| Endpoint | Description | | +|-----------------------------|------------------|-----------| +| `https://listmonk.yoursite.com/webhooks/service/ses` | Amazon (AWS) SES | You can use these [Mautic steps](https://docs.mautic.org/en/channels/emails/bounce-management#amazon-webhook) as a general guide, but use your listmonk's endpoint instead.
* When creating the *topic* select "standard" instead of the preselected "FIFO". You can put a name and leave everything else at default.
* When creating a *subscription* choose HTTPS for "Protocol", and leave *"Enable raw message delivery"* UNCHECKED.
* On the _"SES -> verified identities"_ page, make sure to check **"[include original headers](https://github.com/knadh/listmonk/issues/720#issuecomment-1046877192)"**.
* The Mautic screenshot suggests you should turn off _email feedback forwarding_, but that's completely optional depending on whether you want want email notifications. | +| `https://listmonk.yoursite.com/webhooks/service/sendgrid` | Sendgrid / Twilio Signed event webhook | [More info](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) | diff --git a/docs/docs/content/concepts.md b/docs/docs/content/concepts.md new file mode 100644 index 00000000..ede5841d --- /dev/null +++ b/docs/docs/content/concepts.md @@ -0,0 +1,72 @@ +# Concepts + +## Subscriber + +A subscriber is a recipient identified by an e-mail address and name. Subscribers receive e-mails that are sent from listmonk. A subscriber can be added to any number of lists. Subscribers who are not a part of any lists are considered *orphan* records. + +### Attributes + +Attributes are arbitrary properties attached to a subscriber in addition to their e-mail and name. They are represented as a JSON map. It is not necessary for all subscribers to have the same attributes. Subscribers can be [queried and segmented](../querying-and-segmentation) into lists based on their attributes, and the attributes can be inserted into the e-mails sent to them. For example: + +```json +{ + "city": "Bengaluru", + "likes_tea": true, + "spoken_languages": ["English", "Malayalam"], + "projects": 3, + "stack": { + "frameworks": ["echo", "go"], + "languages": ["go", "python"], + "preferred_language": "go" + } +} +``` + +### Subscription statuses + +A subscriber can be added to one or more lists, and each such relationship can have one of these statuses. + +| Status | Description | +| ------------- | --------------------------------------------------------------------------------- | +| `unconfirmed` | The subscriber was added to the list directly without their explicit confirmation. Nonetheless, the subscriber will receive campaign messages sent to single optin campaigns. | +| `confirmed` | The subscriber confirmed their subscription by clicking on 'accept' in the confirmation e-mail. Only confirmed subscribers in opt-in lists will receive campaign messages send to the list. | +| `unsubscribed` | The subscriber is unsubscribed from the list and will not receive any campaign messages sent to the list. + + +### Segmentation + +Segmentation is the process of filtering a large list of subscribers into a smaller group based on arbitrary conditions, primarily based on their attributes. For instance, if an e-mail needs to be sent subscribers who live in a particular city, given their city is described in their attributes, it's possible to quickly filter them out into a new list and e-mail them. [Learn more](../querying-and-segmentation). + +## List + +A list (or a _mailing list_) is a collection of subscribers grouped under a name, for instance, _clients_. Lists are used to organise subscribers and send e-mails to specific groups. A list can be single optin or double optin. Subscribers added to double optin lists have to explicitly accept the subscription by clicking on the confirmation e-mail they receive. Until then, they do not receive campaign messages. + +## Campaign + +A campaign is an e-mail (or any other kind of messages) that is sent to one or more lists. + + +## Transactional message + +A transactional message is an arbitrary message sent to a subscriber using the transactional message API. For example a welcome e-mail on signing up to a service; an order confirmation e-mail on purchasing an item; a password reset e-mail when a user initiates an online account recovery process. + + +## Template + +A template is a re-usable HTML design that can be used across campaigns and when sending arbitrary transactional messages. Most commonly, templates have standard header and footer areas with logos and branding elements, where campaign content is inserted in the middle. listmonk supports [Go template](https://gowebexamples.com/templates/) expressions that lets you create powerful, dynamic HTML templates. [Learn more](../templating). + +## Messenger + +listmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, enabling not just e-mail campaigns, but arbitrary message campaigns such as SMS, FCM notifications etc. A *Messenger* is a web service that accepts a campaign message pushed to it as a JSON request, which the service can in turn broadcast as SMS, FCM etc. [Learn more](../messengers). + +## Tracking pixel + +The tracking pixel is a tiny, invisible image that is inserted into an e-mail body to track e-mail views. This allows measuring the read rate of e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track reads anonymously without associating an e-mail read to a subscriber. + +## Click tracking + +It is possible to track the clicks on every link that is sent in an e-mail. This allows measuring the clickthrough rates of links in e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track link clicks anonymously without associating an e-mail read to a subscriber. + +## Bounce + +A bounce occurs when an e-mail that is sent to a recipient "bounces" back for one of many reasons including the recipient address being invalid, their mailbox being full, or the recipient's e-mail service provider marking the e-mail as spam. listmonk can automatically process such bounce e-mails that land in a configured POP mailbox, or via APIs of SMTP e-mail providers such as AWS SES and Sengrid. Based on settings, subscribers returning bounced e-mails can either be blocklisted or deleted automatically. [Learn more](../bounces). diff --git a/docs/docs/content/configuration.md b/docs/docs/content/configuration.md new file mode 100644 index 00000000..c47d3dd9 --- /dev/null +++ b/docs/docs/content/configuration.md @@ -0,0 +1,84 @@ +# Configuration + +### TOML Configuration file +One or more TOML files can be read by passing `--config config.toml` multiple times. Apart from a few low level configuration variables and the database configuration, all other settings can be managed from the `Settings` dashboard on the admin UI. + +To generate a new sample configuration file, run `--listmonk --new-config` + +### Environment variables +Variables in config.toml can also be provided as environment variables prefixed by `LISTMONK_` with periods replaced by `__` (double underscore). Example: + +| **Environment variable** | Example value | +|--------------------------------|----------------| +| `LISTMONK_app__address` | "0.0.0.0:9000" | +| `LISTMONK_app__admin_username` | listmonk | +| `LISTMONK_app__admin_password` | listmonk | +| `LISTMONK_db__host` | db | +| `LISTMONK_db__port` | 9432 | +| `LISTMONK_db__user` | listmonk | +| `LISTMONK_db__password` | listmonk | +| `LISTMONK_db__database` | listmonk | +| `LISTMONK_db__ssl_mode` | disable | + + +### Customizing system templates +[Read this](../templating/#system-templates) + + +### HTTP routes +When configuring auth proxies and web application firewalls, use this table. + +#### Private admin endpoints. + +| Methods | Route | Description | +|---------|--------------------|-------------------------| +| `*` | `/api/*` | Admin APIs | +| `GET` | `/admin/*` | Admin UI and HTML pages | +| `POST` | `/webhooks/bounce` | Admin bounce webhook | + + +#### Public endpoints to expose to the internet. + +| Methods | Route | Description | +|-------------|-----------------------|-----------------------------------------------| +| `GET, POST` | `/subscription/*` | HTML subscription pages | +| `GET, ` | `/link/*` | Tracked link redirection | +| `GET` | `/campaign/*` | Pixel tracking image | +| `GET` | `/public/*` | Static files for HTML subscription pages | +| `POST` | `/webhooks/service/*` | Bounce webhook endpoints for AWS and Sendgrid | + + +## Media Uploads + +### Filesystem + +When configuring `docker` volume mounts for using filesystem media uploads, you can follow either of two approaches. + +#### Using volumes + +Using `docker volumes`, you can specify the name of volume and destination for the files to be uploaded inside the container. + + +```yml +app: + volumes: + - type: volume + source: listmonk-uploads + target: /listmonk/uploads + +volumes: + listmonk-uploads: +``` + +!!! note + + This volume is managed by `docker` itself, and you can see find the host path with `docker volume inspect listmonk_listmonk-uploads`. + +#### Using bind mounts + +```yml + volumes: + - /data/uploads:/listmonk/uploads +``` + +The files will be available inside `/data/uploads` directory on the host machine. diff --git a/docs/docs/content/developer-setup.md b/docs/docs/content/developer-setup.md new file mode 100644 index 00000000..dc23034c --- /dev/null +++ b/docs/docs/content/developer-setup.md @@ -0,0 +1,27 @@ +# Developer setup +The app has two distinct components, the Go backend and the VueJS frontend. In the dev environment, both are run independently. + + +### Pre-requisites +- `go` +- `nodejs` (if you are working on the frontend) and `yarn` +- Postgres database. If there is no local installation, the demo docker DB can be used for development (`docker-compose up demo-db`) + + +### First time setup +`git clone https://github.com/knadh/listmonk.git`. The project uses go.mod, so it's best to clone it outside the Go src path. + +1. Copy `config.toml.sample` as `config.toml` and add your config. +2. `make dist` to build the listmonk binary. Once the binary is built, run `./listmonk --install` to run the DB setup. For subsequent dev runs, use `make run`. + +> [mailhog](https://github.com/mailhog/MailHog) is an excellent standalone mock SMTP server (with a UI) for testing and dev. + + +### Running the dev environment +1. Run `make run` to start the listmonk dev server on `:9000`. +2. Run `make run-frontend` to start the Vue frontend in dev mode using yarn on `:8080`. All `/api/*` calls are proxied to the app running on `:9000`. Refer to the [frontend README](https://github.com/knadh/listmonk/blob/master/frontend/README.md) for an overview on how the frontend is structured. +3. Visit `http://localhost:8080` + + +# Production build +Run `make dist` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `listmonk` diff --git a/docs/docs/content/external-integration.md b/docs/docs/content/external-integration.md new file mode 100644 index 00000000..d402b4fa --- /dev/null +++ b/docs/docs/content/external-integration.md @@ -0,0 +1,11 @@ +# Integrating with external systems + +In many environments, a mailing list manager's subscriber database is not run independently but as a part of an existing customer database or a CRM. There are multiple ways of keeping listmonk in sync with external systems. + +## Using APIs + +The [subscriber APIs](../apis/subscribers) offers several APIs to manipulate the subscribers database, like addition, updation, and deletion. For bulk synchronisation, a CSV can be generated (and optionally zipped) and posted to the import API. + +## Interacting directly with the DB + +listmonk uses tables with simple schemas to represent subscribers (`subscribers`), lists (`lists`), and subscriptions (`subscriber_lists`). It is easy to add, update, and delete subscriber information directly with the database tables for advanced usecases. See the [table schemas](https://github.com/knadh/listmonk/blob/master/schema.sql) for more information. diff --git a/docs/docs/content/i18n.md b/docs/docs/content/i18n.md new file mode 100644 index 00000000..2b3b0a63 --- /dev/null +++ b/docs/docs/content/i18n.md @@ -0,0 +1,25 @@ +# Internationalization (i18n) + +listmonk comes available in multiple languages thanks to language packs contributed by volunteers. A language pack is a JSON file with a map of keys and corresponding translations. The bundled languages can be [viewed here](https://github.com/knadh/listmonk/tree/master/i18n). + +## Additional language packs +These additional language packs can be downloaded and passed to listmonk with the `--i18n-dir` flag as described in the next section. + +| Language | Description | +|------------------|--------------------------------------| +| [Deutsch (formal)](https://raw.githubusercontent.com/SvenPe/listmonk/4bbb2e5ebb2314b754cb2318f4f6683a0f854d43/i18n/de.json) | German language with formal pronouns | + + +## Customizing languages + +To customize an existing language or to load a new language, put one or more `.json` language files in a directory, and pass the directory path to listmonk with the
`--i18n-dir=/path/to/dir` flag. + + +## Contributing a new language + +- Visit [https://listmonk.app/i18n](https://listmonk.app/i18n) +- To make changes to an existing language, use the "Load language" option on the top right. +- To create a new language, use the "Load language" option on the top right and select "Default". +- Translate the text in the input fields on the UI. +- Once done, use the `Switch to raw JSON` and copy the JSON data and save it to a file named `xx.json`, where `xx` is the two letter code of the language. +- Send a pull request to add the file to the [i18n directory on the GitHub repo](https://github.com/knadh/listmonk/tree/master/i18n). If you are not familiar with pull requests, share the file by creating a new "issue" (comment) on the [GitHub issues page](https://github.com/knadh/listmonk/issues) diff --git a/docs/docs/content/images/2021-09-28_00-18.png b/docs/docs/content/images/2021-09-28_00-18.png new file mode 100644 index 00000000..73859654 Binary files /dev/null and b/docs/docs/content/images/2021-09-28_00-18.png differ diff --git a/docs/docs/content/images/edit-subscriber.png b/docs/docs/content/images/edit-subscriber.png new file mode 100644 index 00000000..af7eb8af Binary files /dev/null and b/docs/docs/content/images/edit-subscriber.png differ diff --git a/docs/docs/content/images/favicon.png b/docs/docs/content/images/favicon.png new file mode 100644 index 00000000..0ca8f02b Binary files /dev/null and b/docs/docs/content/images/favicon.png differ diff --git a/docs/docs/content/images/logo.svg b/docs/docs/content/images/logo.svg new file mode 100644 index 00000000..d3d36e75 --- /dev/null +++ b/docs/docs/content/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/docs/content/images/query-subscribers.png b/docs/docs/content/images/query-subscribers.png new file mode 100644 index 00000000..f3cc638b Binary files /dev/null and b/docs/docs/content/images/query-subscribers.png differ diff --git a/docs/docs/content/images/splash.png b/docs/docs/content/images/splash.png new file mode 100644 index 00000000..8ad87b5a Binary files /dev/null and b/docs/docs/content/images/splash.png differ diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md new file mode 100644 index 00000000..be2019b9 --- /dev/null +++ b/docs/docs/content/index.md @@ -0,0 +1,10 @@ +# Introduction + +[![listmonk](images/logo.svg)](https://listmonk.app) + +listmonk is a self-hosted, high performance mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database. + +[![listmonk screenshot](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app) + +## Developers +listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/knadh/listmonk) and refer to the [developer setup](developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI. diff --git a/docs/docs/content/installation.md b/docs/docs/content/installation.md new file mode 100644 index 00000000..9a94077b --- /dev/null +++ b/docs/docs/content/installation.md @@ -0,0 +1,136 @@ +# Installation + +listmonk requires Postgres ⩾ v9.4. + +## Binary +- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. +- `./listmonk --new-config` to generate config.toml. Then, edit the file. +- `./listmonk --install` to install the tables in the Postgres DB. +- Run `./listmonk` and visit `http://localhost:9000`. + + +## Docker + +The latest image is available on DockerHub at `listmonk/listmonk:latest` + +Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with docker-compose as follows: + +### Demo + +#### Easy Docker install + +```bash +mkdir listmonk-demo +sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)" +``` + +#### Manual Docker install + +```bash +wget -O docker-compose.yml https://raw.githubusercontent.com/knadh/listmonk/master/docker-compose.yml +docker-compose up -d demo-db demo-app +``` + +!!! warning + The demo does not persist Postgres after the containers are removed. **DO NOT** use this demo setup in production. + +### Production + +#### Easy Docker install + +This setup is recommended if you want to _quickly_ setup `listmonk` in production. + +```bash +mkdir listmonk +sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)" +``` + +The above shell script performs the following actions: + +- Downloads `docker-compose.yml` and generates a `config.toml`. +- Runs a Postgres container and installs the database schema. +- Runs the `listmonk` container. + +!!! note + It's recommended to examine the contents of the shell script, before running in your environment. + +#### Manual Docker install + +The following workflow is recommended to setup `listmonk` manually using `docker-compose`. You are encouraged to customise the contents of `docker-compose.yml` to your needs. The overall setup looks like: + +- `docker-compose up db` to run the Postgres DB. +- `docker-compose run --rm app ./listmonk --install` to setup the DB (or `--upgrade` to upgrade an existing DB). +- Copy `config.toml.sample` to your directory and make the following changes: + - `app.address` => `0.0.0.0:9000` (Port forwarding on Docker will work only if the app is advertising on all interfaces.) + - `db.host` => `listmonk_db` (Container Name of the DB container) +- Run `docker-compose up app` and visit `http://localhost:9000`. + +##### Mounting a custom config.toml + +To mount a local `config.toml` file, add the following section to `docker-compose.yml`: + +```yml + app: + <<: *app-defaults + depends_on: + - db + volumes: + - ./path/on/your/host/config.toml:/listmonk/config.toml +``` + +!!! note + Some common changes done inside `config.toml` for Docker based setups: + + - Change `app.address` to `0.0.0.0:9000`. + - Change `db.host` to `listmonk_db`. + +Here's a sample `config.toml` you can use: + +```toml +[app] +address = "0.0.0.0:9000" +admin_username = "listmonk" +admin_password = "listmonk" + +# Database. +[db] +host = "listmonk_db" +port = 5432 +user = "listmonk" +password = "listmonk" +database = "listmonk" +ssl_mode = "disable" +max_open = 25 +max_idle = 25 +max_lifetime = "300s" +``` + +Mount the local `config.toml` inside the container at `listmonk/config.toml`. + +!!! tip + - See [configuring with environment variables](../configuration) for variables like `app.admin_password` and `db.password` + - Ensure that both `app` and `db` containers are in running. If the containers are not running, restart them `docker-compose restart app db`. + - Refer to [this tutorial](https://yasoob.me/posts/setting-up-listmonk-opensource-newsletter-mailing/) for setting up a production instance with Docker + Nginx + LetsEncrypt SSL. + +!!! info + The example `docker-compose.yml` file works with Docker Engine 18.06.0+ and `docker-compose` which supports file format 3.7. + +## Compiling from source + +To compile the latest unreleased version (`master` branch): + +1. Make sure `go`, `nodejs`, and `yarn` are installed on your system. +2. `git clone git@github.com:knadh/listmonk.git` +3. `cd listmonk && make dist`. This will generate the `listmonk binary`. + +## Release candidate (RC) + +The `master` branch with bleeding edge changes is periodically built and published as `listmonk/listmonk:rc` on DockerHub. To run the latest pre-release version, replace all instances of `listmonk/listmonk:latest` with `listmonk/listmonk:rc` in the docker-compose.yml file and follow the Docker installation steps above. While it is generally safe to run release candidate versions, they may have issues that only get resolved in a general release. + +## 3rd party hosting + + +One-click deploy on Raleway +
+Deploy on PikaPod +Tutorial for deploying on Fly.io diff --git a/docs/docs/content/messengers.md b/docs/docs/content/messengers.md new file mode 100644 index 00000000..90fbf4cb --- /dev/null +++ b/docs/docs/content/messengers.md @@ -0,0 +1,43 @@ +# Messengers + +listmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, enabling not just e-mail campaigns, but arbitrary message campaigns such as SMS, FCM notifications etc. + +A *Messenger* is a web service that accepts a campaign message pushed to it as a JSON request, which the service can in turn broadcast as SMS, FCM etc. Messengers are registered in the *Settings -> Messengers* UI, and can be selected on individual campaigns. + +Messengers support optional BasicAuth authentication. `Plain text` format for campaign content is ideal for messengers such as SMS and FCM. + +When a campaign starts, listmonk POSTs messages in the following format to the selected messenger's endpoint. The endpoint should return a `200 OK` response in case of a successful request. + +The address required to broadcast the message, for instance, a phone number or an FCM ID, is expected to be stored and relayed as [subscriber attributes](../concepts/#attributes). + +```json +{ + "subject": "Welcome to listmonk", + "body": "The message body", + "content_type": "plain", + "recipients": [{ + "uuid": "e44b4135-1e1d-40c5-8a30-0f9a886c2884", + "email": "anon@example.com", + "name": "Anon Doe", + "attribs": { + "phone": "123123123", + "fcm_id": "2e7e4b512e7e4b512e7e4b51", + "city": "Bengaluru" + }, + "status": "enabled" + }], + "campaign": { + "uuid": "2e7e4b51-f31b-418a-a120-e41800cb689f", + "name": "Test campaign", + "tags": ["test-campaign"] + } +} +``` + +## Messenger implementations + +Following is a list of HTTP messenger servers that connect to various backends. + +| Name | Backend | +|------------------------------------------------------------------------|------------------| +| [listmonk-messenger](https://github.com/joeirimpan/listmonk-messenger) | AWS Pinpoint SMS | diff --git a/docs/docs/content/querying-and-segmentation.md b/docs/docs/content/querying-and-segmentation.md new file mode 100644 index 00000000..9ead585b --- /dev/null +++ b/docs/docs/content/querying-and-segmentation.md @@ -0,0 +1,95 @@ +# Querying and segmenting subscribers + +listmonk allows the writing of partial Postgres SQL expressions to query, filter, and segment subscribers. + +## Database fields + +These are the fields in the subscriber database that can be queried. + +| Field | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `subscribers.uuid` | The randomly generated unique ID of the subscriber | +| `subscribers.email` | E-mail ID of the subscriber | +| `subscribers.name` | Name of the subscriber | +| `subscribers.status` | Status of the subscriber (enabled, disabled, blocklisted) | +| `subscribers.attribs` | Map of arbitrary attributes represented as JSON. Accessed via the `->` and `->>` Postgres operator. | +| `subscribers.created_at` | Timestamp when the subscriber was first added | +| `subscribers.updated_at` | Timestamp when the subscriber was modified | + +## Sample attributes + +Here's a sample JSON map of attributes assigned to an imaginary subscriber. + +```json +{ + "city": "Bengaluru", + "likes_tea": true, + "spoken_languages": ["English", "Malayalam"], + "projects": 3, + "stack": { + "frameworks": ["echo", "go"], + "languages": ["go", "python"], + "preferred_language": "go" + } +} +``` + +![listmonk screenshot](images/edit-subscriber.png) + +## Sample SQL query expressions + +![listmonk](images/query-subscribers.png) + +#### Find a subscriber by e-mail + +```sql +-- Exact match +subscribers.email = 'some@domain.com' + +-- Partial match to find e-mails that end in @domain.com. +subscribers.email LIKE '%@domain.com' + +``` + +#### Find a subscriber by name + +```sql +-- Find all subscribers whose name start with John. +subscribers.email LIKE 'John%' + +``` + +#### Multiple conditions + +```sql +-- Find all Johns who have been blocklisted. +subscribers.email LIKE 'John%' AND status = 'blocklisted' +``` + +#### Querying attributes + +```sql +-- The ->> operator returns the value as text. Find all subscribers +-- who live in Bengaluru and have done more than 3 projects. +-- Here 'projects' is cast into an integer so that we can apply the +-- numerical operator > +subscribers.attribs->>'city' = 'Bengaluru' AND + (subscribers.attribs->>'projects')::INT > 3 +``` + +#### Querying nested attributes + +```sql +-- Find all blocklisted subscribers who like to drink tea, can code Python +-- and prefer coding Go. +-- +-- The -> operator returns the value as a structure. Here, the "languages" field +-- The ? operator checks for the existence of a value in a list. +subscribers.status = 'blocklisted' AND + (subscribers.attribs->>'likes_tea')::BOOLEAN = true AND + subscribers.attribs->'stack'->'languages' ? 'python' AND + subscribers.attribs->'stack'->>'preferred_language' = 'go' + +``` + +To learn how to write SQL expressions to do advancd querying on JSON attributes, refer to the Postgres [JSONB documentation](https://www.postgresql.org/docs/11/functions-json.html). diff --git a/docs/docs/content/static/style.css b/docs/docs/content/static/style.css new file mode 100644 index 00000000..1196e26f --- /dev/null +++ b/docs/docs/content/static/style.css @@ -0,0 +1,100 @@ +body[data-md-color-primary="white"] .md-header[data-md-state="shadow"] { + background: #fff; + box-shadow: none; + color: #333; + + box-shadow: 1px 1px 3px #ddd; +} + +.md-typeset .md-typeset__table table { + border: 1px solid #ddd; + box-shadow: 2px 2px 0 #f3f3f3; + overflow: inherit; +} + +body[data-md-color-primary="white"] .md-search__input { + background: #f6f6f6; + color: #333; +} + +body[data-md-color-primary="white"] + .md-sidebar--secondary + .md-sidebar__scrollwrap { + background: #f6f6f6; + padding: 10px 0; +} + +body[data-md-color-primary="white"] .md-nav__item--active { + font-weight: 600; + color: inherit; +} +body[data-md-color-primary="white"] .md-nav__item--active a { + color: #0055d4; +} +body[data-md-color-primary="white"] .md-nav__item a:hover { + color: #0055d4; +} + +body[data-md-color-primary="white"] thead, +body[data-md-color-primary="white"] .md-typeset table:not([class]) th { + background: #f6f6f6; + border: 0; + color: inherit; + font-weight: 600; +} +table td span { + font-size: 0.85em; + color: #bbb; + display: block; +} + +.md-typeset h1, .md-typeset h2 { + font-weight: 500; +} + +body[data-md-color-primary="white"] .md-typeset h1 { + margin: 4rem 0 0 0; + color: inherit; + border-top: 1px solid #ddd; + padding-top: 2rem; +} +body[data-md-color-primary="white"] .md-typeset h2 { + border-top: 1px solid #eee; + padding-top: 2rem; +} + +body[data-md-color-primary="white"] .md-content h1:first-child { + margin: 0 0 3rem 0; + padding: 0; + border: 0; +} + +body[data-md-color-primary="white"] .md-typeset code { + word-break: normal; +} + +li img { + background: #fff; + border-radius: 6px; + border: 1px solid #e6e6e6; + box-shadow: 1px 1px 4px #e6e6e6; + padding: 5px; + margin-top: 10px; +} + +/* This hack places the #anchor-links correctly +by accommodating for the fixed-header's height */ +:target:before { + content: ""; + display: block; + height: 120px; + margin-top: -120px; +} + +.md-typeset a { + color: #0055d4; +} +.md-typeset a:hover { + color: #666 !important; + text-decoration: underline; +} diff --git a/docs/docs/content/templating.md b/docs/docs/content/templating.md new file mode 100644 index 00000000..912c39e1 --- /dev/null +++ b/docs/docs/content/templating.md @@ -0,0 +1,170 @@ +# Templating + +A template is a re-usable HTML design that can be used across campaigns and transactional messages. Most commonly, templates have standard header and footer areas with logos and branding elements, where campaign content is inserted in the middle. listmonk supports Go template expressions that lets you create powerful, dynamic HTML templates. + +listmonk supports [Go template](https://gowebexamples.com/templates/) expressions that lets you create powerful, dynamic HTML templates. It also integrates 100+ useful [Sprig template functions](https://masterminds.github.io/sprig/). + +## Campaign templates +Campaign templates are used in an e-mail campaigns. These template are created and managed on the UI under `Campaigns -> Templates`, and are selected when creating new campaigns. + +## Transactional templates +Transactional templates are used for sending arbitrary transactional messages using the transactional API. These template are created and managed on the UI under `Campaigns -> Templates`. + +## Template expressions + +There are several template functions and expressions that can be used in campaign and template bodies. They are written in the form `{{ .Subscriber.Email }}`, that is, an expression between double curly braces `{{` and `}}`. + +### Subscriber fields + +| Expression | Description | +| ----------------------------- | -------------------------------------------------------------------------------------------- | +| `{{ .Subscriber.UUID }}` | The randomly generated unique ID of the subscriber | +| `{{ .Subscriber.Email }}` | E-mail ID of the subscriber | +| `{{ .Subscriber.Name }}` | Name of the subscriber | +| `{{ .Subscriber.FirstName }}` | First name of the subscriber (automatically extracted from the name) | +| `{{ .Subscriber.LastName }}` | Last name of the subscriber (automatically extracted from the name) | +| `{{ .Subscriber.Status }}` | Status of the subscriber (enabled, disabled, blocklisted) | +| `{{ .Subscriber.Attribs }}` | Map of arbitrary attributes. Fields can be accessed with `.`, eg: `.Subscriber.Attribs.city` | +| `{{ .Subscriber.CreatedAt }}` | Timestamp when the subscriber was first added | +| `{{ .Subscriber.UpdatedAt }}` | Timestamp when the subscriber was modified | + +| Expression | Description | +| --------------------- | -------------------------------------------------------- | +| `{{ .Campaign.UUID }}` | The randomly generated unique ID of the campaign | +| `{{ .Campaign.Name }}` | Internal name of the campaign | +| `{{ .Campaign.Subject }}` | E-mail subject of the campaign | +| `{{ .Campaign.FromEmail }}` | The e-mail address from which the campaign is being sent | + +### Functions + +| Function | Description | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{{ Date "2006-01-01" }}` | Prints the current datetime for the given format expressed as a [Go date layout](https://yourbasic.org/golang/format-parse-string-time-date-example/) | +| `{{ TrackLink "https://link.com" }}` | Takes a URL and generates a tracking URL over it. For use in campaign bodies and templates. | +| `https://link.com@TrackLink` | Shorthand for `TrackLink`. Eg: `Link` | +| `{{ TrackView }}` | Inserts a single tracking pixel. Should only be used once, ideally in the template footer. | +| `{{ UnsubscribeURL }}` | Unsubscription and Manage preferences URL. Ideal for use in the template footer. | +| `{{ MessageURL }}` | URL to view the hosted version of an e-mail message. | +| `{{ OptinURL }}` | URL to the double-optin confirmation page. | +| `{{ Safe "" }}` | Add any HTML code as it is. | + +### Sprig functions +listmonk integrates the Sprig library that offers 100+ utility functions for working with strings, numbers, dates etc. that can be used in templating. Refer to the [Sprig documentation](https://masterminds.github.io/sprig/) for the full list of functions. + + +### Example template + +The expression `{{ template "content" . }}` should appear exactly once in every template denoting the spot where an e-mail's content is inserted. Here's a sample HTML e-mail that has a fixed header and footer that inserts the content in the middle. + +```html + + + + + + +
+
+ + Hi {{ .Subscriber.FirstName }}! +
+ + +
+ {{ template "content" . }} +
+ +
+ Copyright 2019. All rights Reserved. +
+ + + {{ TrackView }} +
+ + +``` + +!!! info + For use with plaintext campaigns, create a template with no HTML content and just the placeholder `{{ template "content" . }}` + +### Example campaign body + +Campaign bodies can be composed using the built-in WYSIWYG editor or as raw HTML documents. Assuming that the subscriber has a set of [attributes defined](../querying-and-segmentation#sample-attributes), this example shows how to render those values in a campaign. + +``` +Hey, did you notice how the template showed your first name? +Your last name is {{.Subscriber.LastName }}. + +You have done {{ .Subscriber.Attribs.projects }} projects. + + +{{ if eq .Subscriber.Attribs.city "Bengaluru" }} + You live in Bangalore! +{{ else }} + Where do you live? +{{ end }} + + +Here is a link for you to click that will be tracked. +Google + +``` + +The above example uses an `if` condition to show one of two messages depending on the value of a subscriber attribute. Many such dynamic expressions are possible with Go templating expressions. + +## System templates +System templates are used for rendering public user facing pages such as the subscription management page, and in automatically generated system e-mails such as the opt-in confirmation e-mail. These are bundled into listmonk but can be customized by copying the [static directory](https://github.com/knadh/listmonk/tree/master/static) locally, and passing its path to listmonk with the `./listmonk --static-dir=your/custom/path` flag. + + +### Public pages + +| /static/public/ | | +|------------------------|--------------------------------------------------------------------| +| `index.html` | Base template with the header and footer that all pages use. | +| `home.html` | Landing page on the root domain with the login button. | +| `message.html` | Generic success / failure message page. | +| `optin.html` | Opt-in confirmation page. | +| `subscription.html` | Subscription management page with options for data export and wipe. | +| `subscription-form.html` | List selection and subscription form page. | + + +To edit the appearance of the public pages using CSS and Javascript, head to Settings > Appearance > Public: + +![image](https://user-images.githubusercontent.com/55474996/153739792-93074af6-d1dd-40aa-8cde-c02ea4bbb67b.png) + + + +### System e-mails + +| /static/email-templates/ | | +|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| `base.html` | Base template with the header and footer that all system generated e-mails use. | +| `campaign-status.html` | E-mail notification that is sent to admins on campaign start, completion etc. | +| `import-status.html` | E-mail notification that is sent to admins on finish of an import job. | +| `subscriber-data.html` | E-mail that is sent to subscribers when they request a full dump of their private data. | +| `subscriber-optin.html` | Automatic opt-in confirmation e-mail that is sent to an unconfirmed subscriber when they are added. | +| `subscriber-optin-campaign.html` | E-mail content that's inserted into a campaign body when starting an opt-in campaign from the lists page. | +| `default.tpl` | Default campaign template that is created in Campaigns -> Templates when listmonk is first installed. This is not used after that. | + +!!! info + To turn system e-mail templates to plaintext, remove `` from base.html and remove all HTML tags from the templates while retaining the Go templating code. diff --git a/docs/docs/content/upgrade.md b/docs/docs/content/upgrade.md new file mode 100644 index 00000000..a240121f --- /dev/null +++ b/docs/docs/content/upgrade.md @@ -0,0 +1,20 @@ +# Upgrade + +Some versions may require changes to the database. These changes or database "migrations" are applied automatically and safely, but, it is recommended to take a backup of the Postgres database before running the `--upgrade` option, especially if you have made customizations to the database tables. + +## Binary +- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. +- `./listmonk --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`. + +## Docker + +- `docker-compose pull` to pull the latest version from DockerHub. +- `docker-compose run --rm app ./listmonk --upgrade` to upgrade an existing DB. +- Run `docker-compose up app db` and visit `http://localhost:9000`. + +## Railway +- Head to your dashboard, and select your Listmonk project. +- Select the GitHub deployment service. +- In the Deployment tab, head to the latest deployment, click on the three vertical dots to the right, and select "Redeploy". +- ![Railway Redeploy option](https://user-images.githubusercontent.com/55474996/226517149-6dc512d5-f862-46f7-a57d-5e55b781ff53.png) diff --git a/docs/docs/mkdocs.yml b/docs/docs/mkdocs.yml new file mode 100644 index 00000000..81952edd --- /dev/null +++ b/docs/docs/mkdocs.yml @@ -0,0 +1,58 @@ +site_name: listmonk / Documentation +theme: + name: material + # custom_dir: "mkdocs-material/material" + logo: "images/favicon.png" + favicon: "images/favicon.png" + language: "en" + font: + text: 'Inter' + weights: 400 + direction: 'ltr' + extra: + search: + language: 'en' + feature: + tabs: true + + palette: + primary: "white" + accent: "red" + +site_dir: _out +docs_dir: content + +markdown_extensions: + - admonition + - pymdownx.highlight + - pymdownx.superfences + - toc: + permalink: true + +extra_css: + - "static/style.css" + +copyright: "Copyright © 2019-2023, Kailash Nadh." + +nav: + - "Introduction": index.md + - "Installation": installation.md + - "Upgrade": upgrade.md + - "Configuration": configuration.md + - "Developer setup": developer-setup.md + - "Concepts": concepts.md + - "Querying and segmenting subscribers": querying-and-segmentation.md + - "Templating": templating.md + - "Bounce processing": bounces.md + - "Messengers": "messengers.md" + - "Internationalization": "i18n.md" + - "Integrating with external systems": external-integration.md + - "API": apis/apis.md + - "API / Subscribers": apis/subscribers.md + - "API / Lists": apis/lists.md + - "API / Import": apis/import.md + - "API / Campaigns": apis/campaigns.md + - "API / Media": apis/media.md + - "API / Templates": apis/templates.md + - "API / Transactional": apis/transactional.md + diff --git a/docs/i18n/index.html b/docs/i18n/index.html new file mode 100644 index 00000000..cdf06395 --- /dev/null +++ b/docs/i18n/index.html @@ -0,0 +1,96 @@ + + + + listmonk i18n translation editor + + + + + + +
+
+

{{ values["_.name"] }}

+
+ + +
+ + + +
+ +
+ Load language + +
+
+
+ +

+ Changes are stored in the browser's localStorage until the cache is cleared. + To edit an existing language, load it and edit the fields. + To create a new language, load the default language and edit the fields. + Once done, copy the raw JSON and send a PR to the + repo. +

+ +
+
+

{{ k.head }}

+ +
+
{{ i + 1 }}.
+
+ {{ base[k.key] }} + + +
+
+
+
+ +
+ +
+
+

Loading ...

+ + + + + \ No newline at end of file diff --git a/docs/i18n/main.js b/docs/i18n/main.js new file mode 100644 index 00000000..65f52c78 --- /dev/null +++ b/docs/i18n/main.js @@ -0,0 +1,156 @@ +const BASEURL = "https://raw.githubusercontent.com/knadh/listmonk/master/i18n/"; +const BASELANG = "en"; + +var app = new Vue({ + el: "#app", + data: { + base: {}, + keys: [], + visibleKeys: {}, + values: {}, + view: "all", + loadLang: BASELANG, + + isRawVisible: false, + rawData: "{}" + }, + + methods: { + init() { + document.querySelector("#app").style.display = 'block'; + document.querySelector("#loading").remove(); + }, + + loadBaseLang(url) { + return fetch(url).then(response => response.json()).then(data => { + // Retain the base values. + Object.assign(this.base, data); + + // Get the sorted keys from the language map. + const keys = []; + const visibleKeys = {}; + let head = null; + Object.entries(this.base).sort((a, b) => a[0].localeCompare(b[0])).forEach((v) => { + const h = v[0].split('.')[0]; + keys.push({ + "key": v[0], + "head": (head !== h ? h : null) // eg: campaigns on `campaigns.something.else` + }); + + visibleKeys[v[0]] = true; + head = h; + }); + + this.keys = keys; + this.visibleKeys = visibleKeys; + this.values = { ...this.base }; + + // Is there cached localStorage data? + if(localStorage.data) { + try { + this.loadData(JSON.parse(localStorage.data)); + } catch(e) { + console.log("Bad JSON in localStorage: " + e.toString()); + } + return; + } + }); + }, + + loadData(data) { + // Filter out all keys from data except for the base ones + // in the base language. + const vals = this.keys.reduce((a, key) => { + a[key.key] = data.hasOwnProperty(key.key) ? data[key.key] : this.base[key.key]; + return a; + }, {}); + + this.values = vals; + this.saveData(); + }, + + saveData() { + localStorage.data = JSON.stringify(this.values); + }, + + // Has a key been translated (changed from the base)? + isDone(key) { + return this.values[key] && this.base[key] !== this.values[key]; + }, + + isItemVisible(key) { + return this.visibleKeys[key]; + }, + + onToggleRaw() { + if (!this.isRawVisible) { + this.rawData = JSON.stringify(this.values, Object.keys(this.values).sort(), 4); + } else { + try { + this.loadData(JSON.parse(this.rawData)); + } catch (e) { + alert("error parsing JSON: " + e.toString()); + return false; + } + } + + this.isRawVisible = !this.isRawVisible; + }, + + onLoadLanguage() { + if(!confirm("Loading this language will overwrite your local changes. Continue?")) { + return false; + } + + fetch(BASEURL + this.loadLang + ".json").then(response => response.json()).then(data => { + this.loadData(data); + }).catch((e) => { + console.log(e); + alert("error fetching file: " + e.toString()); + }); + } + }, + + mounted() { + this.loadBaseLang(BASEURL + BASELANG + ".json").then(() => this.init()); + }, + + watch: { + view(v) { + // When the view changes, create a copy of the items to be filtered + // by and filter the view based on that. Otherwise, the moment the value + // in the input changes, the list re-renders making items disappear. + + const visibleKeys = {}; + this.keys.forEach((k) => { + let visible = true; + + if (v === "pending") { + visible = !this.isDone(k.key); + } else if (v === "complete") { + visible = this.isDone(k.key); + } + + if(visible) { + visibleKeys[k.key] = true; + } + }); + + this.visibleKeys = visibleKeys; + } + }, + + computed: { + completed() { + let n = 0; + + this.keys.forEach(k => { + if(this.values[k.key] !== this.base[k.key]) { + n++; + } + }); + + return n; + } + } +}); diff --git a/docs/i18n/style.css b/docs/i18n/style.css new file mode 100644 index 00000000..23d1da31 --- /dev/null +++ b/docs/i18n/style.css @@ -0,0 +1,106 @@ +* { + box-sizing: border-box; +} + +body { + font-family: Inter, "Helvetica Neue", "Segoe UI", sans-serif; + font-size: 16px; + line-height: 24px; +} + +h1, h2, h3, h4, h5 { + margin: 0 0 15px 0; +} + +.container { + padding: 30px; +} + +.header { + align-items: center; + margin-bottom: 30px; +} + .header .controls { + display: flex; + } + .header .controls .pending { + color: #ff3300; + } + .header .controls .complete { + color: #05a200; + } + .header .title { + margin: 0 0 15px 0; + } + .header .block { + margin: 0 45px 0 0; + } + .header .view label { + cursor: pointer; + margin-right: 10px; + display: inline-block; + } + +#app { + display: none; +} + +.data .key, +.data .base { + display: block; + color: #777; + display: block; +} + .data .item { + padding: 15px; + clear: both; + } + .data .item:hover { + background: #eee; + } + .data .item.done .num { + color: #05a200; + } + .data .item.done .num::after { + content: '✓'; + font-weight: bold; + } + + .data .controls { + display: flex; + } + .data .fields { + flex-grow: 1; + } + .data .num { + margin-right: 15px; + min-width: 50px; + } + .data .key { + color: #aaa; + font-size: 0.875em; + } + .data input { + width: 100%; + border: 1px solid #ddd; + padding: 5px; + display: block; + margin: 3px 0; + + } + .data input:focus { + border-color: #666; + } + .data p { + margin: 0 0 3px 0; + } + .data .head { + margin: 0 0 15px 0; + } + +.raw textarea { + border: 1px solid #ddd; + padding: 5px; + width: 100%; + height: 90vh; +} \ No newline at end of file diff --git a/docs/i18n/vue.min.js b/docs/i18n/vue.min.js new file mode 100644 index 00000000..41094e00 --- /dev/null +++ b/docs/i18n/vue.min.js @@ -0,0 +1,6 @@ +/*! + * Vue.js v2.6.12 + * (c) 2014-2020 Evan You + * Released under the MIT License. + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).Vue=t()}(this,function(){"use strict";var e=Object.freeze({});function t(e){return null==e}function n(e){return null!=e}function r(e){return!0===e}function i(e){return"string"==typeof e||"number"==typeof e||"symbol"==typeof e||"boolean"==typeof e}function o(e){return null!==e&&"object"==typeof e}var a=Object.prototype.toString;function s(e){return"[object Object]"===a.call(e)}function c(e){var t=parseFloat(String(e));return t>=0&&Math.floor(t)===t&&isFinite(e)}function u(e){return n(e)&&"function"==typeof e.then&&"function"==typeof e.catch}function l(e){return null==e?"":Array.isArray(e)||s(e)&&e.toString===a?JSON.stringify(e,null,2):String(e)}function f(e){var t=parseFloat(e);return isNaN(t)?e:t}function p(e,t){for(var n=Object.create(null),r=e.split(","),i=0;i-1)return e.splice(n,1)}}var m=Object.prototype.hasOwnProperty;function y(e,t){return m.call(e,t)}function g(e){var t=Object.create(null);return function(n){return t[n]||(t[n]=e(n))}}var _=/-(\w)/g,b=g(function(e){return e.replace(_,function(e,t){return t?t.toUpperCase():""})}),$=g(function(e){return e.charAt(0).toUpperCase()+e.slice(1)}),w=/\B([A-Z])/g,C=g(function(e){return e.replace(w,"-$1").toLowerCase()});var x=Function.prototype.bind?function(e,t){return e.bind(t)}:function(e,t){function n(n){var r=arguments.length;return r?r>1?e.apply(t,arguments):e.call(t,n):e.call(t)}return n._length=e.length,n};function k(e,t){t=t||0;for(var n=e.length-t,r=new Array(n);n--;)r[n]=e[n+t];return r}function A(e,t){for(var n in t)e[n]=t[n];return e}function O(e){for(var t={},n=0;n0,Z=J&&J.indexOf("edge/")>0,G=(J&&J.indexOf("android"),J&&/iphone|ipad|ipod|ios/.test(J)||"ios"===K),X=(J&&/chrome\/\d+/.test(J),J&&/phantomjs/.test(J),J&&J.match(/firefox\/(\d+)/)),Y={}.watch,Q=!1;if(z)try{var ee={};Object.defineProperty(ee,"passive",{get:function(){Q=!0}}),window.addEventListener("test-passive",null,ee)}catch(e){}var te=function(){return void 0===B&&(B=!z&&!V&&"undefined"!=typeof global&&(global.process&&"server"===global.process.env.VUE_ENV)),B},ne=z&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__;function re(e){return"function"==typeof e&&/native code/.test(e.toString())}var ie,oe="undefined"!=typeof Symbol&&re(Symbol)&&"undefined"!=typeof Reflect&&re(Reflect.ownKeys);ie="undefined"!=typeof Set&&re(Set)?Set:function(){function e(){this.set=Object.create(null)}return e.prototype.has=function(e){return!0===this.set[e]},e.prototype.add=function(e){this.set[e]=!0},e.prototype.clear=function(){this.set=Object.create(null)},e}();var ae=S,se=0,ce=function(){this.id=se++,this.subs=[]};ce.prototype.addSub=function(e){this.subs.push(e)},ce.prototype.removeSub=function(e){h(this.subs,e)},ce.prototype.depend=function(){ce.target&&ce.target.addDep(this)},ce.prototype.notify=function(){for(var e=this.subs.slice(),t=0,n=e.length;t-1)if(o&&!y(i,"default"))a=!1;else if(""===a||a===C(e)){var c=Pe(String,i.type);(c<0||s0&&(st((u=e(u,(a||"")+"_"+c))[0])&&st(f)&&(s[l]=he(f.text+u[0].text),u.shift()),s.push.apply(s,u)):i(u)?st(f)?s[l]=he(f.text+u):""!==u&&s.push(he(u)):st(u)&&st(f)?s[l]=he(f.text+u.text):(r(o._isVList)&&n(u.tag)&&t(u.key)&&n(a)&&(u.key="__vlist"+a+"_"+c+"__"),s.push(u)));return s}(e):void 0}function st(e){return n(e)&&n(e.text)&&!1===e.isComment}function ct(e,t){if(e){for(var n=Object.create(null),r=oe?Reflect.ownKeys(e):Object.keys(e),i=0;i0,a=t?!!t.$stable:!o,s=t&&t.$key;if(t){if(t._normalized)return t._normalized;if(a&&r&&r!==e&&s===r.$key&&!o&&!r.$hasNormal)return r;for(var c in i={},t)t[c]&&"$"!==c[0]&&(i[c]=pt(n,c,t[c]))}else i={};for(var u in n)u in i||(i[u]=dt(n,u));return t&&Object.isExtensible(t)&&(t._normalized=i),R(i,"$stable",a),R(i,"$key",s),R(i,"$hasNormal",o),i}function pt(e,t,n){var r=function(){var e=arguments.length?n.apply(null,arguments):n({});return(e=e&&"object"==typeof e&&!Array.isArray(e)?[e]:at(e))&&(0===e.length||1===e.length&&e[0].isComment)?void 0:e};return n.proxy&&Object.defineProperty(e,t,{get:r,enumerable:!0,configurable:!0}),r}function dt(e,t){return function(){return e[t]}}function vt(e,t){var r,i,a,s,c;if(Array.isArray(e)||"string"==typeof e)for(r=new Array(e.length),i=0,a=e.length;idocument.createEvent("Event").timeStamp&&(sn=function(){return cn.now()})}function un(){var e,t;for(an=sn(),rn=!0,Qt.sort(function(e,t){return e.id-t.id}),on=0;onon&&Qt[n].id>e.id;)n--;Qt.splice(n+1,0,e)}else Qt.push(e);nn||(nn=!0,Ye(un))}}(this)},fn.prototype.run=function(){if(this.active){var e=this.get();if(e!==this.value||o(e)||this.deep){var t=this.value;if(this.value=e,this.user)try{this.cb.call(this.vm,e,t)}catch(e){Re(e,this.vm,'callback for watcher "'+this.expression+'"')}else this.cb.call(this.vm,e,t)}}},fn.prototype.evaluate=function(){this.value=this.get(),this.dirty=!1},fn.prototype.depend=function(){for(var e=this.deps.length;e--;)this.deps[e].depend()},fn.prototype.teardown=function(){if(this.active){this.vm._isBeingDestroyed||h(this.vm._watchers,this);for(var e=this.deps.length;e--;)this.deps[e].removeSub(this);this.active=!1}};var pn={enumerable:!0,configurable:!0,get:S,set:S};function dn(e,t,n){pn.get=function(){return this[t][n]},pn.set=function(e){this[t][n]=e},Object.defineProperty(e,n,pn)}function vn(e){e._watchers=[];var t=e.$options;t.props&&function(e,t){var n=e.$options.propsData||{},r=e._props={},i=e.$options._propKeys=[];e.$parent&&$e(!1);var o=function(o){i.push(o);var a=Me(o,t,n,e);xe(r,o,a),o in e||dn(e,"_props",o)};for(var a in t)o(a);$e(!0)}(e,t.props),t.methods&&function(e,t){e.$options.props;for(var n in t)e[n]="function"!=typeof t[n]?S:x(t[n],e)}(e,t.methods),t.data?function(e){var t=e.$options.data;s(t=e._data="function"==typeof t?function(e,t){le();try{return e.call(t,t)}catch(e){return Re(e,t,"data()"),{}}finally{fe()}}(t,e):t||{})||(t={});var n=Object.keys(t),r=e.$options.props,i=(e.$options.methods,n.length);for(;i--;){var o=n[i];r&&y(r,o)||(a=void 0,36!==(a=(o+"").charCodeAt(0))&&95!==a&&dn(e,"_data",o))}var a;Ce(t,!0)}(e):Ce(e._data={},!0),t.computed&&function(e,t){var n=e._computedWatchers=Object.create(null),r=te();for(var i in t){var o=t[i],a="function"==typeof o?o:o.get;r||(n[i]=new fn(e,a||S,S,hn)),i in e||mn(e,i,o)}}(e,t.computed),t.watch&&t.watch!==Y&&function(e,t){for(var n in t){var r=t[n];if(Array.isArray(r))for(var i=0;i-1:"string"==typeof e?e.split(",").indexOf(t)>-1:(n=e,"[object RegExp]"===a.call(n)&&e.test(t));var n}function An(e,t){var n=e.cache,r=e.keys,i=e._vnode;for(var o in n){var a=n[o];if(a){var s=xn(a.componentOptions);s&&!t(s)&&On(n,o,r,i)}}}function On(e,t,n,r){var i=e[t];!i||r&&i.tag===r.tag||i.componentInstance.$destroy(),e[t]=null,h(n,t)}!function(t){t.prototype._init=function(t){var n=this;n._uid=bn++,n._isVue=!0,t&&t._isComponent?function(e,t){var n=e.$options=Object.create(e.constructor.options),r=t._parentVnode;n.parent=t.parent,n._parentVnode=r;var i=r.componentOptions;n.propsData=i.propsData,n._parentListeners=i.listeners,n._renderChildren=i.children,n._componentTag=i.tag,t.render&&(n.render=t.render,n.staticRenderFns=t.staticRenderFns)}(n,t):n.$options=De($n(n.constructor),t||{},n),n._renderProxy=n,n._self=n,function(e){var t=e.$options,n=t.parent;if(n&&!t.abstract){for(;n.$options.abstract&&n.$parent;)n=n.$parent;n.$children.push(e)}e.$parent=n,e.$root=n?n.$root:e,e.$children=[],e.$refs={},e._watcher=null,e._inactive=null,e._directInactive=!1,e._isMounted=!1,e._isDestroyed=!1,e._isBeingDestroyed=!1}(n),function(e){e._events=Object.create(null),e._hasHookEvent=!1;var t=e.$options._parentListeners;t&&qt(e,t)}(n),function(t){t._vnode=null,t._staticTrees=null;var n=t.$options,r=t.$vnode=n._parentVnode,i=r&&r.context;t.$slots=ut(n._renderChildren,i),t.$scopedSlots=e,t._c=function(e,n,r,i){return Pt(t,e,n,r,i,!1)},t.$createElement=function(e,n,r,i){return Pt(t,e,n,r,i,!0)};var o=r&&r.data;xe(t,"$attrs",o&&o.attrs||e,null,!0),xe(t,"$listeners",n._parentListeners||e,null,!0)}(n),Yt(n,"beforeCreate"),function(e){var t=ct(e.$options.inject,e);t&&($e(!1),Object.keys(t).forEach(function(n){xe(e,n,t[n])}),$e(!0))}(n),vn(n),function(e){var t=e.$options.provide;t&&(e._provided="function"==typeof t?t.call(e):t)}(n),Yt(n,"created"),n.$options.el&&n.$mount(n.$options.el)}}(wn),function(e){var t={get:function(){return this._data}},n={get:function(){return this._props}};Object.defineProperty(e.prototype,"$data",t),Object.defineProperty(e.prototype,"$props",n),e.prototype.$set=ke,e.prototype.$delete=Ae,e.prototype.$watch=function(e,t,n){if(s(t))return _n(this,e,t,n);(n=n||{}).user=!0;var r=new fn(this,e,t,n);if(n.immediate)try{t.call(this,r.value)}catch(e){Re(e,this,'callback for immediate watcher "'+r.expression+'"')}return function(){r.teardown()}}}(wn),function(e){var t=/^hook:/;e.prototype.$on=function(e,n){var r=this;if(Array.isArray(e))for(var i=0,o=e.length;i1?k(t):t;for(var n=k(arguments,1),r='event handler for "'+e+'"',i=0,o=t.length;iparseInt(this.max)&&On(a,s[0],s,this._vnode)),t.data.keepAlive=!0}return t||e&&e[0]}}};!function(e){var t={get:function(){return F}};Object.defineProperty(e,"config",t),e.util={warn:ae,extend:A,mergeOptions:De,defineReactive:xe},e.set=ke,e.delete=Ae,e.nextTick=Ye,e.observable=function(e){return Ce(e),e},e.options=Object.create(null),M.forEach(function(t){e.options[t+"s"]=Object.create(null)}),e.options._base=e,A(e.options.components,Tn),function(e){e.use=function(e){var t=this._installedPlugins||(this._installedPlugins=[]);if(t.indexOf(e)>-1)return this;var n=k(arguments,1);return n.unshift(this),"function"==typeof e.install?e.install.apply(e,n):"function"==typeof e&&e.apply(null,n),t.push(e),this}}(e),function(e){e.mixin=function(e){return this.options=De(this.options,e),this}}(e),Cn(e),function(e){M.forEach(function(t){e[t]=function(e,n){return n?("component"===t&&s(n)&&(n.name=n.name||e,n=this.options._base.extend(n)),"directive"===t&&"function"==typeof n&&(n={bind:n,update:n}),this.options[t+"s"][e]=n,n):this.options[t+"s"][e]}})}(e)}(wn),Object.defineProperty(wn.prototype,"$isServer",{get:te}),Object.defineProperty(wn.prototype,"$ssrContext",{get:function(){return this.$vnode&&this.$vnode.ssrContext}}),Object.defineProperty(wn,"FunctionalRenderContext",{value:Tt}),wn.version="2.6.12";var En=p("style,class"),Nn=p("input,textarea,option,select,progress"),jn=function(e,t,n){return"value"===n&&Nn(e)&&"button"!==t||"selected"===n&&"option"===e||"checked"===n&&"input"===e||"muted"===n&&"video"===e},Dn=p("contenteditable,draggable,spellcheck"),Ln=p("events,caret,typing,plaintext-only"),Mn=function(e,t){return Hn(t)||"false"===t?"false":"contenteditable"===e&&Ln(t)?t:"true"},In=p("allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,translate,truespeed,typemustmatch,visible"),Fn="http://www.w3.org/1999/xlink",Pn=function(e){return":"===e.charAt(5)&&"xlink"===e.slice(0,5)},Rn=function(e){return Pn(e)?e.slice(6,e.length):""},Hn=function(e){return null==e||!1===e};function Bn(e){for(var t=e.data,r=e,i=e;n(i.componentInstance);)(i=i.componentInstance._vnode)&&i.data&&(t=Un(i.data,t));for(;n(r=r.parent);)r&&r.data&&(t=Un(t,r.data));return function(e,t){if(n(e)||n(t))return zn(e,Vn(t));return""}(t.staticClass,t.class)}function Un(e,t){return{staticClass:zn(e.staticClass,t.staticClass),class:n(e.class)?[e.class,t.class]:t.class}}function zn(e,t){return e?t?e+" "+t:e:t||""}function Vn(e){return Array.isArray(e)?function(e){for(var t,r="",i=0,o=e.length;i-1?hr(e,t,n):In(t)?Hn(n)?e.removeAttribute(t):(n="allowfullscreen"===t&&"EMBED"===e.tagName?"true":t,e.setAttribute(t,n)):Dn(t)?e.setAttribute(t,Mn(t,n)):Pn(t)?Hn(n)?e.removeAttributeNS(Fn,Rn(t)):e.setAttributeNS(Fn,t,n):hr(e,t,n)}function hr(e,t,n){if(Hn(n))e.removeAttribute(t);else{if(q&&!W&&"TEXTAREA"===e.tagName&&"placeholder"===t&&""!==n&&!e.__ieph){var r=function(t){t.stopImmediatePropagation(),e.removeEventListener("input",r)};e.addEventListener("input",r),e.__ieph=!0}e.setAttribute(t,n)}}var mr={create:dr,update:dr};function yr(e,r){var i=r.elm,o=r.data,a=e.data;if(!(t(o.staticClass)&&t(o.class)&&(t(a)||t(a.staticClass)&&t(a.class)))){var s=Bn(r),c=i._transitionClasses;n(c)&&(s=zn(s,Vn(c))),s!==i._prevClass&&(i.setAttribute("class",s),i._prevClass=s)}}var gr,_r,br,$r,wr,Cr,xr={create:yr,update:yr},kr=/[\w).+\-_$\]]/;function Ar(e){var t,n,r,i,o,a=!1,s=!1,c=!1,u=!1,l=0,f=0,p=0,d=0;for(r=0;r=0&&" "===(h=e.charAt(v));v--);h&&kr.test(h)||(u=!0)}}else void 0===i?(d=r+1,i=e.slice(0,r).trim()):m();function m(){(o||(o=[])).push(e.slice(d,r).trim()),d=r+1}if(void 0===i?i=e.slice(0,r).trim():0!==d&&m(),o)for(r=0;r-1?{exp:e.slice(0,$r),key:'"'+e.slice($r+1)+'"'}:{exp:e,key:null};_r=e,$r=wr=Cr=0;for(;!zr();)Vr(br=Ur())?Jr(br):91===br&&Kr(br);return{exp:e.slice(0,wr),key:e.slice(wr+1,Cr)}}(e);return null===n.key?e+"="+t:"$set("+n.exp+", "+n.key+", "+t+")"}function Ur(){return _r.charCodeAt(++$r)}function zr(){return $r>=gr}function Vr(e){return 34===e||39===e}function Kr(e){var t=1;for(wr=$r;!zr();)if(Vr(e=Ur()))Jr(e);else if(91===e&&t++,93===e&&t--,0===t){Cr=$r;break}}function Jr(e){for(var t=e;!zr()&&(e=Ur())!==t;);}var qr,Wr="__r",Zr="__c";function Gr(e,t,n){var r=qr;return function i(){null!==t.apply(null,arguments)&&Qr(e,i,n,r)}}var Xr=Ve&&!(X&&Number(X[1])<=53);function Yr(e,t,n,r){if(Xr){var i=an,o=t;t=o._wrapper=function(e){if(e.target===e.currentTarget||e.timeStamp>=i||e.timeStamp<=0||e.target.ownerDocument!==document)return o.apply(this,arguments)}}qr.addEventListener(e,t,Q?{capture:n,passive:r}:n)}function Qr(e,t,n,r){(r||qr).removeEventListener(e,t._wrapper||t,n)}function ei(e,r){if(!t(e.data.on)||!t(r.data.on)){var i=r.data.on||{},o=e.data.on||{};qr=r.elm,function(e){if(n(e[Wr])){var t=q?"change":"input";e[t]=[].concat(e[Wr],e[t]||[]),delete e[Wr]}n(e[Zr])&&(e.change=[].concat(e[Zr],e.change||[]),delete e[Zr])}(i),rt(i,o,Yr,Qr,Gr,r.context),qr=void 0}}var ti,ni={create:ei,update:ei};function ri(e,r){if(!t(e.data.domProps)||!t(r.data.domProps)){var i,o,a=r.elm,s=e.data.domProps||{},c=r.data.domProps||{};for(i in n(c.__ob__)&&(c=r.data.domProps=A({},c)),s)i in c||(a[i]="");for(i in c){if(o=c[i],"textContent"===i||"innerHTML"===i){if(r.children&&(r.children.length=0),o===s[i])continue;1===a.childNodes.length&&a.removeChild(a.childNodes[0])}if("value"===i&&"PROGRESS"!==a.tagName){a._value=o;var u=t(o)?"":String(o);ii(a,u)&&(a.value=u)}else if("innerHTML"===i&&qn(a.tagName)&&t(a.innerHTML)){(ti=ti||document.createElement("div")).innerHTML=""+o+"";for(var l=ti.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;l.firstChild;)a.appendChild(l.firstChild)}else if(o!==s[i])try{a[i]=o}catch(e){}}}}function ii(e,t){return!e.composing&&("OPTION"===e.tagName||function(e,t){var n=!0;try{n=document.activeElement!==e}catch(e){}return n&&e.value!==t}(e,t)||function(e,t){var r=e.value,i=e._vModifiers;if(n(i)){if(i.number)return f(r)!==f(t);if(i.trim)return r.trim()!==t.trim()}return r!==t}(e,t))}var oi={create:ri,update:ri},ai=g(function(e){var t={},n=/:(.+)/;return e.split(/;(?![^(]*\))/g).forEach(function(e){if(e){var r=e.split(n);r.length>1&&(t[r[0].trim()]=r[1].trim())}}),t});function si(e){var t=ci(e.style);return e.staticStyle?A(e.staticStyle,t):t}function ci(e){return Array.isArray(e)?O(e):"string"==typeof e?ai(e):e}var ui,li=/^--/,fi=/\s*!important$/,pi=function(e,t,n){if(li.test(t))e.style.setProperty(t,n);else if(fi.test(n))e.style.setProperty(C(t),n.replace(fi,""),"important");else{var r=vi(t);if(Array.isArray(n))for(var i=0,o=n.length;i-1?t.split(yi).forEach(function(t){return e.classList.add(t)}):e.classList.add(t);else{var n=" "+(e.getAttribute("class")||"")+" ";n.indexOf(" "+t+" ")<0&&e.setAttribute("class",(n+t).trim())}}function _i(e,t){if(t&&(t=t.trim()))if(e.classList)t.indexOf(" ")>-1?t.split(yi).forEach(function(t){return e.classList.remove(t)}):e.classList.remove(t),e.classList.length||e.removeAttribute("class");else{for(var n=" "+(e.getAttribute("class")||"")+" ",r=" "+t+" ";n.indexOf(r)>=0;)n=n.replace(r," ");(n=n.trim())?e.setAttribute("class",n):e.removeAttribute("class")}}function bi(e){if(e){if("object"==typeof e){var t={};return!1!==e.css&&A(t,$i(e.name||"v")),A(t,e),t}return"string"==typeof e?$i(e):void 0}}var $i=g(function(e){return{enterClass:e+"-enter",enterToClass:e+"-enter-to",enterActiveClass:e+"-enter-active",leaveClass:e+"-leave",leaveToClass:e+"-leave-to",leaveActiveClass:e+"-leave-active"}}),wi=z&&!W,Ci="transition",xi="animation",ki="transition",Ai="transitionend",Oi="animation",Si="animationend";wi&&(void 0===window.ontransitionend&&void 0!==window.onwebkittransitionend&&(ki="WebkitTransition",Ai="webkitTransitionEnd"),void 0===window.onanimationend&&void 0!==window.onwebkitanimationend&&(Oi="WebkitAnimation",Si="webkitAnimationEnd"));var Ti=z?window.requestAnimationFrame?window.requestAnimationFrame.bind(window):setTimeout:function(e){return e()};function Ei(e){Ti(function(){Ti(e)})}function Ni(e,t){var n=e._transitionClasses||(e._transitionClasses=[]);n.indexOf(t)<0&&(n.push(t),gi(e,t))}function ji(e,t){e._transitionClasses&&h(e._transitionClasses,t),_i(e,t)}function Di(e,t,n){var r=Mi(e,t),i=r.type,o=r.timeout,a=r.propCount;if(!i)return n();var s=i===Ci?Ai:Si,c=0,u=function(){e.removeEventListener(s,l),n()},l=function(t){t.target===e&&++c>=a&&u()};setTimeout(function(){c0&&(n=Ci,l=a,f=o.length):t===xi?u>0&&(n=xi,l=u,f=c.length):f=(n=(l=Math.max(a,u))>0?a>u?Ci:xi:null)?n===Ci?o.length:c.length:0,{type:n,timeout:l,propCount:f,hasTransform:n===Ci&&Li.test(r[ki+"Property"])}}function Ii(e,t){for(;e.length1}function Ui(e,t){!0!==t.data.show&&Pi(t)}var zi=function(e){var o,a,s={},c=e.modules,u=e.nodeOps;for(o=0;ov?_(e,t(i[y+1])?null:i[y+1].elm,i,d,y,o):d>y&&$(r,p,v)}(p,h,y,o,l):n(y)?(n(e.text)&&u.setTextContent(p,""),_(p,null,y,0,y.length-1,o)):n(h)?$(h,0,h.length-1):n(e.text)&&u.setTextContent(p,""):e.text!==i.text&&u.setTextContent(p,i.text),n(v)&&n(d=v.hook)&&n(d=d.postpatch)&&d(e,i)}}}function k(e,t,i){if(r(i)&&n(e.parent))e.parent.data.pendingInsert=t;else for(var o=0;o-1,a.selected!==o&&(a.selected=o);else if(N(Wi(a),r))return void(e.selectedIndex!==s&&(e.selectedIndex=s));i||(e.selectedIndex=-1)}}function qi(e,t){return t.every(function(t){return!N(t,e)})}function Wi(e){return"_value"in e?e._value:e.value}function Zi(e){e.target.composing=!0}function Gi(e){e.target.composing&&(e.target.composing=!1,Xi(e.target,"input"))}function Xi(e,t){var n=document.createEvent("HTMLEvents");n.initEvent(t,!0,!0),e.dispatchEvent(n)}function Yi(e){return!e.componentInstance||e.data&&e.data.transition?e:Yi(e.componentInstance._vnode)}var Qi={model:Vi,show:{bind:function(e,t,n){var r=t.value,i=(n=Yi(n)).data&&n.data.transition,o=e.__vOriginalDisplay="none"===e.style.display?"":e.style.display;r&&i?(n.data.show=!0,Pi(n,function(){e.style.display=o})):e.style.display=r?o:"none"},update:function(e,t,n){var r=t.value;!r!=!t.oldValue&&((n=Yi(n)).data&&n.data.transition?(n.data.show=!0,r?Pi(n,function(){e.style.display=e.__vOriginalDisplay}):Ri(n,function(){e.style.display="none"})):e.style.display=r?e.__vOriginalDisplay:"none")},unbind:function(e,t,n,r,i){i||(e.style.display=e.__vOriginalDisplay)}}},eo={name:String,appear:Boolean,css:Boolean,mode:String,type:String,enterClass:String,leaveClass:String,enterToClass:String,leaveToClass:String,enterActiveClass:String,leaveActiveClass:String,appearClass:String,appearActiveClass:String,appearToClass:String,duration:[Number,String,Object]};function to(e){var t=e&&e.componentOptions;return t&&t.Ctor.options.abstract?to(zt(t.children)):e}function no(e){var t={},n=e.$options;for(var r in n.propsData)t[r]=e[r];var i=n._parentListeners;for(var o in i)t[b(o)]=i[o];return t}function ro(e,t){if(/\d-keep-alive$/.test(t.tag))return e("keep-alive",{props:t.componentOptions.propsData})}var io=function(e){return e.tag||Ut(e)},oo=function(e){return"show"===e.name},ao={name:"transition",props:eo,abstract:!0,render:function(e){var t=this,n=this.$slots.default;if(n&&(n=n.filter(io)).length){var r=this.mode,o=n[0];if(function(e){for(;e=e.parent;)if(e.data.transition)return!0}(this.$vnode))return o;var a=to(o);if(!a)return o;if(this._leaving)return ro(e,o);var s="__transition-"+this._uid+"-";a.key=null==a.key?a.isComment?s+"comment":s+a.tag:i(a.key)?0===String(a.key).indexOf(s)?a.key:s+a.key:a.key;var c=(a.data||(a.data={})).transition=no(this),u=this._vnode,l=to(u);if(a.data.directives&&a.data.directives.some(oo)&&(a.data.show=!0),l&&l.data&&!function(e,t){return t.key===e.key&&t.tag===e.tag}(a,l)&&!Ut(l)&&(!l.componentInstance||!l.componentInstance._vnode.isComment)){var f=l.data.transition=A({},c);if("out-in"===r)return this._leaving=!0,it(f,"afterLeave",function(){t._leaving=!1,t.$forceUpdate()}),ro(e,o);if("in-out"===r){if(Ut(a))return u;var p,d=function(){p()};it(c,"afterEnter",d),it(c,"enterCancelled",d),it(f,"delayLeave",function(e){p=e})}}return o}}},so=A({tag:String,moveClass:String},eo);function co(e){e.elm._moveCb&&e.elm._moveCb(),e.elm._enterCb&&e.elm._enterCb()}function uo(e){e.data.newPos=e.elm.getBoundingClientRect()}function lo(e){var t=e.data.pos,n=e.data.newPos,r=t.left-n.left,i=t.top-n.top;if(r||i){e.data.moved=!0;var o=e.elm.style;o.transform=o.WebkitTransform="translate("+r+"px,"+i+"px)",o.transitionDuration="0s"}}delete so.mode;var fo={Transition:ao,TransitionGroup:{props:so,beforeMount:function(){var e=this,t=this._update;this._update=function(n,r){var i=Zt(e);e.__patch__(e._vnode,e.kept,!1,!0),e._vnode=e.kept,i(),t.call(e,n,r)}},render:function(e){for(var t=this.tag||this.$vnode.data.tag||"span",n=Object.create(null),r=this.prevChildren=this.children,i=this.$slots.default||[],o=this.children=[],a=no(this),s=0;s-1?Gn[e]=t.constructor===window.HTMLUnknownElement||t.constructor===window.HTMLElement:Gn[e]=/HTMLUnknownElement/.test(t.toString())},A(wn.options.directives,Qi),A(wn.options.components,fo),wn.prototype.__patch__=z?zi:S,wn.prototype.$mount=function(e,t){return function(e,t,n){var r;return e.$el=t,e.$options.render||(e.$options.render=ve),Yt(e,"beforeMount"),r=function(){e._update(e._render(),n)},new fn(e,r,S,{before:function(){e._isMounted&&!e._isDestroyed&&Yt(e,"beforeUpdate")}},!0),n=!1,null==e.$vnode&&(e._isMounted=!0,Yt(e,"mounted")),e}(this,e=e&&z?Yn(e):void 0,t)},z&&setTimeout(function(){F.devtools&&ne&&ne.emit("init",wn)},0);var po=/\{\{((?:.|\r?\n)+?)\}\}/g,vo=/[-.*+?^${}()|[\]\/\\]/g,ho=g(function(e){var t=e[0].replace(vo,"\\$&"),n=e[1].replace(vo,"\\$&");return new RegExp(t+"((?:.|\\n)+?)"+n,"g")});var mo={staticKeys:["staticClass"],transformNode:function(e,t){t.warn;var n=Fr(e,"class");n&&(e.staticClass=JSON.stringify(n));var r=Ir(e,"class",!1);r&&(e.classBinding=r)},genData:function(e){var t="";return e.staticClass&&(t+="staticClass:"+e.staticClass+","),e.classBinding&&(t+="class:"+e.classBinding+","),t}};var yo,go={staticKeys:["staticStyle"],transformNode:function(e,t){t.warn;var n=Fr(e,"style");n&&(e.staticStyle=JSON.stringify(ai(n)));var r=Ir(e,"style",!1);r&&(e.styleBinding=r)},genData:function(e){var t="";return e.staticStyle&&(t+="staticStyle:"+e.staticStyle+","),e.styleBinding&&(t+="style:("+e.styleBinding+"),"),t}},_o=function(e){return(yo=yo||document.createElement("div")).innerHTML=e,yo.textContent},bo=p("area,base,br,col,embed,frame,hr,img,input,isindex,keygen,link,meta,param,source,track,wbr"),$o=p("colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr,source"),wo=p("address,article,aside,base,blockquote,body,caption,col,colgroup,dd,details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,title,tr,track"),Co=/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,xo=/^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,ko="[a-zA-Z_][\\-\\.0-9_a-zA-Z"+P.source+"]*",Ao="((?:"+ko+"\\:)?"+ko+")",Oo=new RegExp("^<"+Ao),So=/^\s*(\/?)>/,To=new RegExp("^<\\/"+Ao+"[^>]*>"),Eo=/^]+>/i,No=/^",""":'"',"&":"&"," ":"\n"," ":"\t","'":"'"},Io=/&(?:lt|gt|quot|amp|#39);/g,Fo=/&(?:lt|gt|quot|amp|#39|#10|#9);/g,Po=p("pre,textarea",!0),Ro=function(e,t){return e&&Po(e)&&"\n"===t[0]};function Ho(e,t){var n=t?Fo:Io;return e.replace(n,function(e){return Mo[e]})}var Bo,Uo,zo,Vo,Ko,Jo,qo,Wo,Zo=/^@|^v-on:/,Go=/^v-|^@|^:|^#/,Xo=/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/,Yo=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,Qo=/^\(|\)$/g,ea=/^\[.*\]$/,ta=/:(.*)$/,na=/^:|^\.|^v-bind:/,ra=/\.[^.\]]+(?=[^\]]*$)/g,ia=/^v-slot(:|$)|^#/,oa=/[\r\n]/,aa=/\s+/g,sa=g(_o),ca="_empty_";function ua(e,t,n){return{type:1,tag:e,attrsList:t,attrsMap:ma(t),rawAttrsMap:{},parent:n,children:[]}}function la(e,t){Bo=t.warn||Sr,Jo=t.isPreTag||T,qo=t.mustUseProp||T,Wo=t.getTagNamespace||T;t.isReservedTag;zo=Tr(t.modules,"transformNode"),Vo=Tr(t.modules,"preTransformNode"),Ko=Tr(t.modules,"postTransformNode"),Uo=t.delimiters;var n,r,i=[],o=!1!==t.preserveWhitespace,a=t.whitespace,s=!1,c=!1;function u(e){if(l(e),s||e.processed||(e=fa(e,t)),i.length||e===n||n.if&&(e.elseif||e.else)&&da(n,{exp:e.elseif,block:e}),r&&!e.forbidden)if(e.elseif||e.else)a=e,(u=function(e){var t=e.length;for(;t--;){if(1===e[t].type)return e[t];e.pop()}}(r.children))&&u.if&&da(u,{exp:a.elseif,block:a});else{if(e.slotScope){var o=e.slotTarget||'"default"';(r.scopedSlots||(r.scopedSlots={}))[o]=e}r.children.push(e),e.parent=r}var a,u;e.children=e.children.filter(function(e){return!e.slotScope}),l(e),e.pre&&(s=!1),Jo(e.tag)&&(c=!1);for(var f=0;f]*>)","i")),p=e.replace(f,function(e,n,r){return u=r.length,Do(l)||"noscript"===l||(n=n.replace(//g,"$1").replace(//g,"$1")),Ro(l,n)&&(n=n.slice(1)),t.chars&&t.chars(n),""});c+=e.length-p.length,e=p,A(l,c-u,c)}else{var d=e.indexOf("<");if(0===d){if(No.test(e)){var v=e.indexOf("--\x3e");if(v>=0){t.shouldKeepComment&&t.comment(e.substring(4,v),c,c+v+3),C(v+3);continue}}if(jo.test(e)){var h=e.indexOf("]>");if(h>=0){C(h+2);continue}}var m=e.match(Eo);if(m){C(m[0].length);continue}var y=e.match(To);if(y){var g=c;C(y[0].length),A(y[1],g,c);continue}var _=x();if(_){k(_),Ro(_.tagName,e)&&C(1);continue}}var b=void 0,$=void 0,w=void 0;if(d>=0){for($=e.slice(d);!(To.test($)||Oo.test($)||No.test($)||jo.test($)||(w=$.indexOf("<",1))<0);)d+=w,$=e.slice(d);b=e.substring(0,d)}d<0&&(b=e),b&&C(b.length),t.chars&&b&&t.chars(b,c-b.length,c)}if(e===n){t.chars&&t.chars(e);break}}function C(t){c+=t,e=e.substring(t)}function x(){var t=e.match(Oo);if(t){var n,r,i={tagName:t[1],attrs:[],start:c};for(C(t[0].length);!(n=e.match(So))&&(r=e.match(xo)||e.match(Co));)r.start=c,C(r[0].length),r.end=c,i.attrs.push(r);if(n)return i.unarySlash=n[1],C(n[0].length),i.end=c,i}}function k(e){var n=e.tagName,c=e.unarySlash;o&&("p"===r&&wo(n)&&A(r),s(n)&&r===n&&A(n));for(var u=a(n)||!!c,l=e.attrs.length,f=new Array(l),p=0;p=0&&i[a].lowerCasedTag!==s;a--);else a=0;if(a>=0){for(var u=i.length-1;u>=a;u--)t.end&&t.end(i[u].tag,n,o);i.length=a,r=a&&i[a-1].tag}else"br"===s?t.start&&t.start(e,[],!0,n,o):"p"===s&&(t.start&&t.start(e,[],!1,n,o),t.end&&t.end(e,n,o))}A()}(e,{warn:Bo,expectHTML:t.expectHTML,isUnaryTag:t.isUnaryTag,canBeLeftOpenTag:t.canBeLeftOpenTag,shouldDecodeNewlines:t.shouldDecodeNewlines,shouldDecodeNewlinesForHref:t.shouldDecodeNewlinesForHref,shouldKeepComment:t.comments,outputSourceRange:t.outputSourceRange,start:function(e,o,a,l,f){var p=r&&r.ns||Wo(e);q&&"svg"===p&&(o=function(e){for(var t=[],n=0;nc&&(s.push(o=e.slice(c,i)),a.push(JSON.stringify(o)));var u=Ar(r[1].trim());a.push("_s("+u+")"),s.push({"@binding":u}),c=i+r[0].length}return c-1"+("true"===o?":("+t+")":":_q("+t+","+o+")")),Mr(e,"change","var $$a="+t+",$$el=$event.target,$$c=$$el.checked?("+o+"):("+a+");if(Array.isArray($$a)){var $$v="+(r?"_n("+i+")":i)+",$$i=_i($$a,$$v);if($$el.checked){$$i<0&&("+Br(t,"$$a.concat([$$v])")+")}else{$$i>-1&&("+Br(t,"$$a.slice(0,$$i).concat($$a.slice($$i+1))")+")}}else{"+Br(t,"$$c")+"}",null,!0)}(e,r,i);else if("input"===o&&"radio"===a)!function(e,t,n){var r=n&&n.number,i=Ir(e,"value")||"null";Er(e,"checked","_q("+t+","+(i=r?"_n("+i+")":i)+")"),Mr(e,"change",Br(t,i),null,!0)}(e,r,i);else if("input"===o||"textarea"===o)!function(e,t,n){var r=e.attrsMap.type,i=n||{},o=i.lazy,a=i.number,s=i.trim,c=!o&&"range"!==r,u=o?"change":"range"===r?Wr:"input",l="$event.target.value";s&&(l="$event.target.value.trim()"),a&&(l="_n("+l+")");var f=Br(t,l);c&&(f="if($event.target.composing)return;"+f),Er(e,"value","("+t+")"),Mr(e,u,f,null,!0),(s||a)&&Mr(e,"blur","$forceUpdate()")}(e,r,i);else if(!F.isReservedTag(o))return Hr(e,r,i),!1;return!0},text:function(e,t){t.value&&Er(e,"textContent","_s("+t.value+")",t)},html:function(e,t){t.value&&Er(e,"innerHTML","_s("+t.value+")",t)}},isPreTag:function(e){return"pre"===e},isUnaryTag:bo,mustUseProp:jn,canBeLeftOpenTag:$o,isReservedTag:Wn,getTagNamespace:Zn,staticKeys:function(e){return e.reduce(function(e,t){return e.concat(t.staticKeys||[])},[]).join(",")}(ba)},xa=g(function(e){return p("type,tag,attrsList,attrsMap,plain,parent,children,attrs,start,end,rawAttrsMap"+(e?","+e:""))});function ka(e,t){e&&($a=xa(t.staticKeys||""),wa=t.isReservedTag||T,function e(t){t.static=function(e){if(2===e.type)return!1;if(3===e.type)return!0;return!(!e.pre&&(e.hasBindings||e.if||e.for||d(e.tag)||!wa(e.tag)||function(e){for(;e.parent;){if("template"!==(e=e.parent).tag)return!1;if(e.for)return!0}return!1}(e)||!Object.keys(e).every($a)))}(t);if(1===t.type){if(!wa(t.tag)&&"slot"!==t.tag&&null==t.attrsMap["inline-template"])return;for(var n=0,r=t.children.length;n|^function(?:\s+[\w$]+)?\s*\(/,Oa=/\([^)]*?\);*$/,Sa=/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/,Ta={esc:27,tab:9,enter:13,space:32,up:38,left:37,right:39,down:40,delete:[8,46]},Ea={esc:["Esc","Escape"],tab:"Tab",enter:"Enter",space:[" ","Spacebar"],up:["Up","ArrowUp"],left:["Left","ArrowLeft"],right:["Right","ArrowRight"],down:["Down","ArrowDown"],delete:["Backspace","Delete","Del"]},Na=function(e){return"if("+e+")return null;"},ja={stop:"$event.stopPropagation();",prevent:"$event.preventDefault();",self:Na("$event.target !== $event.currentTarget"),ctrl:Na("!$event.ctrlKey"),shift:Na("!$event.shiftKey"),alt:Na("!$event.altKey"),meta:Na("!$event.metaKey"),left:Na("'button' in $event && $event.button !== 0"),middle:Na("'button' in $event && $event.button !== 1"),right:Na("'button' in $event && $event.button !== 2")};function Da(e,t){var n=t?"nativeOn:":"on:",r="",i="";for(var o in e){var a=La(e[o]);e[o]&&e[o].dynamic?i+=o+","+a+",":r+='"'+o+'":'+a+","}return r="{"+r.slice(0,-1)+"}",i?n+"_d("+r+",["+i.slice(0,-1)+"])":n+r}function La(e){if(!e)return"function(){}";if(Array.isArray(e))return"["+e.map(function(e){return La(e)}).join(",")+"]";var t=Sa.test(e.value),n=Aa.test(e.value),r=Sa.test(e.value.replace(Oa,""));if(e.modifiers){var i="",o="",a=[];for(var s in e.modifiers)if(ja[s])o+=ja[s],Ta[s]&&a.push(s);else if("exact"===s){var c=e.modifiers;o+=Na(["ctrl","shift","alt","meta"].filter(function(e){return!c[e]}).map(function(e){return"$event."+e+"Key"}).join("||"))}else a.push(s);return a.length&&(i+=function(e){return"if(!$event.type.indexOf('key')&&"+e.map(Ma).join("&&")+")return null;"}(a)),o&&(i+=o),"function($event){"+i+(t?"return "+e.value+"($event)":n?"return ("+e.value+")($event)":r?"return "+e.value:e.value)+"}"}return t||n?e.value:"function($event){"+(r?"return "+e.value:e.value)+"}"}function Ma(e){var t=parseInt(e,10);if(t)return"$event.keyCode!=="+t;var n=Ta[e],r=Ea[e];return"_k($event.keyCode,"+JSON.stringify(e)+","+JSON.stringify(n)+",$event.key,"+JSON.stringify(r)+")"}var Ia={on:function(e,t){e.wrapListeners=function(e){return"_g("+e+","+t.value+")"}},bind:function(e,t){e.wrapData=function(n){return"_b("+n+",'"+e.tag+"',"+t.value+","+(t.modifiers&&t.modifiers.prop?"true":"false")+(t.modifiers&&t.modifiers.sync?",true":"")+")"}},cloak:S},Fa=function(e){this.options=e,this.warn=e.warn||Sr,this.transforms=Tr(e.modules,"transformCode"),this.dataGenFns=Tr(e.modules,"genData"),this.directives=A(A({},Ia),e.directives);var t=e.isReservedTag||T;this.maybeComponent=function(e){return!!e.component||!t(e.tag)},this.onceId=0,this.staticRenderFns=[],this.pre=!1};function Pa(e,t){var n=new Fa(t);return{render:"with(this){return "+(e?Ra(e,n):'_c("div")')+"}",staticRenderFns:n.staticRenderFns}}function Ra(e,t){if(e.parent&&(e.pre=e.pre||e.parent.pre),e.staticRoot&&!e.staticProcessed)return Ha(e,t);if(e.once&&!e.onceProcessed)return Ba(e,t);if(e.for&&!e.forProcessed)return za(e,t);if(e.if&&!e.ifProcessed)return Ua(e,t);if("template"!==e.tag||e.slotTarget||t.pre){if("slot"===e.tag)return function(e,t){var n=e.slotName||'"default"',r=qa(e,t),i="_t("+n+(r?","+r:""),o=e.attrs||e.dynamicAttrs?Ga((e.attrs||[]).concat(e.dynamicAttrs||[]).map(function(e){return{name:b(e.name),value:e.value,dynamic:e.dynamic}})):null,a=e.attrsMap["v-bind"];!o&&!a||r||(i+=",null");o&&(i+=","+o);a&&(i+=(o?"":",null")+","+a);return i+")"}(e,t);var n;if(e.component)n=function(e,t,n){var r=t.inlineTemplate?null:qa(t,n,!0);return"_c("+e+","+Va(t,n)+(r?","+r:"")+")"}(e.component,e,t);else{var r;(!e.plain||e.pre&&t.maybeComponent(e))&&(r=Va(e,t));var i=e.inlineTemplate?null:qa(e,t,!0);n="_c('"+e.tag+"'"+(r?","+r:"")+(i?","+i:"")+")"}for(var o=0;o>>0}(a):"")+")"}(e,e.scopedSlots,t)+","),e.model&&(n+="model:{value:"+e.model.value+",callback:"+e.model.callback+",expression:"+e.model.expression+"},"),e.inlineTemplate){var o=function(e,t){var n=e.children[0];if(n&&1===n.type){var r=Pa(n,t.options);return"inlineTemplate:{render:function(){"+r.render+"},staticRenderFns:["+r.staticRenderFns.map(function(e){return"function(){"+e+"}"}).join(",")+"]}"}}(e,t);o&&(n+=o+",")}return n=n.replace(/,$/,"")+"}",e.dynamicAttrs&&(n="_b("+n+',"'+e.tag+'",'+Ga(e.dynamicAttrs)+")"),e.wrapData&&(n=e.wrapData(n)),e.wrapListeners&&(n=e.wrapListeners(n)),n}function Ka(e){return 1===e.type&&("slot"===e.tag||e.children.some(Ka))}function Ja(e,t){var n=e.attrsMap["slot-scope"];if(e.if&&!e.ifProcessed&&!n)return Ua(e,t,Ja,"null");if(e.for&&!e.forProcessed)return za(e,t,Ja);var r=e.slotScope===ca?"":String(e.slotScope),i="function("+r+"){return "+("template"===e.tag?e.if&&n?"("+e.if+")?"+(qa(e,t)||"undefined")+":undefined":qa(e,t)||"undefined":Ra(e,t))+"}",o=r?"":",proxy:true";return"{key:"+(e.slotTarget||'"default"')+",fn:"+i+o+"}"}function qa(e,t,n,r,i){var o=e.children;if(o.length){var a=o[0];if(1===o.length&&a.for&&"template"!==a.tag&&"slot"!==a.tag){var s=n?t.maybeComponent(a)?",1":",0":"";return""+(r||Ra)(a,t)+s}var c=n?function(e,t){for(var n=0,r=0;r':'
',ts.innerHTML.indexOf(" ")>0}var os=!!z&&is(!1),as=!!z&&is(!0),ss=g(function(e){var t=Yn(e);return t&&t.innerHTML}),cs=wn.prototype.$mount;return wn.prototype.$mount=function(e,t){if((e=e&&Yn(e))===document.body||e===document.documentElement)return this;var n=this.$options;if(!n.render){var r=n.template;if(r)if("string"==typeof r)"#"===r.charAt(0)&&(r=ss(r));else{if(!r.nodeType)return this;r=r.innerHTML}else e&&(r=function(e){if(e.outerHTML)return e.outerHTML;var t=document.createElement("div");return t.appendChild(e.cloneNode(!0)),t.innerHTML}(e));if(r){var i=rs(r,{outputSourceRange:!1,shouldDecodeNewlines:os,shouldDecodeNewlinesForHref:as,delimiters:n.delimiters,comments:n.comments},this),o=i.render,a=i.staticRenderFns;n.render=o,n.staticRenderFns=a}}return cs.call(this,e,t)},wn.compile=rs,wn}); \ No newline at end of file diff --git a/docs/site/.hugo_build.lock b/docs/site/.hugo_build.lock new file mode 100644 index 00000000..e69de29b diff --git a/docs/site/config.toml b/docs/site/config.toml new file mode 100644 index 00000000..e5080334 --- /dev/null +++ b/docs/site/config.toml @@ -0,0 +1,6 @@ +baseurl = "https://listmonk.app/" +languageCode = "en-us" +title = "listmonk - Free and open source self-hosted newsletter, mailing list manager, and transactional mails" + +[taxonomies] + tag = "tags" diff --git a/docs/site/content/.gitignore b/docs/site/content/.gitignore new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/site/content/.gitignore @@ -0,0 +1 @@ + diff --git a/docs/site/data/github.json b/docs/site/data/github.json new file mode 100644 index 00000000..9f007462 --- /dev/null +++ b/docs/site/data/github.json @@ -0,0 +1 @@ +{"version":"v2.4.0","date":"2023-03-20T13:54:12Z","url":"https://github.com/knadh/listmonk/releases/tag/v2.4.0","assets":[{"name":"darwin","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_darwin_amd64.tar.gz"},{"name":"freebsd","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_freebsd_amd64.tar.gz"},{"name":"linux","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_linux_amd64.tar.gz"},{"name":"netbsd","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_netbsd_amd64.tar.gz"},{"name":"openbsd","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_openbsd_amd64.tar.gz"},{"name":"windows","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_windows_amd64.tar.gz"}]} diff --git a/docs/site/layouts/index.html b/docs/site/layouts/index.html new file mode 100644 index 00000000..0178aa3e --- /dev/null +++ b/docs/site/layouts/index.html @@ -0,0 +1,219 @@ +{{ partial "header.html" . }} +
+ +
+

Self-hosted newsletter and mailing list manager

+

+ Performance and features packed into a single binary.
+ Free and open source. +

+

+ Live demo +

+
+ +
+ + + + listmonk screenshot +
+
+
+ +
+
+

Download

+

+ The latest version is {{ .Page.Site.Data.github.version }} + released on {{ .Page.Site.Data.github.date | dateFormat "02 Jan 2006" }}. + See release notes. +


+ +
+
+
+

Binary

+
    + +
  • + ./listmonk --new-config to generate config.toml. Edit the file. +
  • +
  • ./listmonk --install to setup the Postgres DB (⩾ v9.4) or --upgrade to upgrade an existing DB.
  • +
  • Run ./listmonk and visit http://localhost:9000
  • +
+

Installation docs →

+ +
+

Hosting providers

+ One-click deploy on Raleway +
+ Deploy on PikaPod +
+ Deploy on Elestio +
+
+
+
+

Docker

+

listmonk/listmonk:latest

+

+ Use the sample docker-compose.yml + to run manually or use the helper script. +

+

Demo

+
mkdir listmonk-demo && cd listmonk-demo
+sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)"
+

+ (DO NOT use this demo setup in production) +

+ +

Production

+
mkdir listmonk && cd listmonk
+sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)"
+

Visit http://localhost:9000

+ +

Installation docs →

+ +

NOTE: Always examine the contents of shell scripts before executing them.

+
+
+
+
+
+ +
+
+

Mailing lists

+
+ Screenshot of list management feature +
+

+ Manage millions of subscribers across many single and double opt-in lists + with custom JSON attributes for each subscriber. + Query and segment subscribers with SQL expressions. +

+

Use the fast bulk importer (~10k records per second) or use HTTP/JSON APIs or interact with the simple + table schema to integrate external CRMs and subscriber databases. +

+
+ +
+

Transactional mails

+
+ Screenshot of transactional API +
+

+ Simple API to send arbitrary transactional messages to subscribers + using pre-defined templates. Send messages as e-mail, SMS, Whatsapp messages or any medium via Messenger interfaces. +

+
+ +
+

Analytics

+
+ Screenshot of analytics feature +
+

+ Simple analaytics and visualizations. Connect external visualization programs to the database easily with the simple table structure. +

+
+ +
+

Templating

+
+ Screenshot of templating feature +
+

+ Create powerful, dynamic e-mail templates with the Go templating language. + Use template expressions, logic, and 100+ functions in subject lines and content. + Write HTML e-mails in a WYSIWYG editor, Markdown, raw syntax-highlighted HTML, or just plain text. +

+
+ +
+

Performance

+
+
+ Screenshot of performance metrics + +
+ A production listmonk instance sending a campaign of 7+ million e-mails.
+ CPU usage is a fraction of a single core with peak RAM usage of 57 MB. +
+
+
+
+

+ Multi-threaded, high-throughput, multi-SMTP e-mail queues. + Throughput and sliding window rate limiting for fine grained control. + Single binary application with nominal CPU and memory footprint that runs everywhere. + The only dependency is a Postgres (⩾ v9.4) database. +

+
+ +
+

Media

+
+ Screenshot of media feature +
+

Use the media manager to upload images for e-mail campaigns + on the server's filesystem, Amazon S3, or any S3 compatible (Minio) backend.

+
+ +
+

Extensible

+
+ Screenshot of Messenger feature +
+

+ More than just e-mail campaigns. Connect HTTP webhooks to send SMS, + Whatsapp, FCM notifications, or any type of messages. +

+
+ +
+

Privacy

+
+ Screenshot of privacy features +
+

+ Allow subscribers to permanently blocklist themselves, export all their data, + and to wipe all their data in a single click. +

+
+ +

and a lot more …

+ +
+
+ Download +
+ + +
+ +{{ partial "footer.html" }} diff --git a/docs/site/layouts/page/single.html b/docs/site/layouts/page/single.html new file mode 100644 index 00000000..143ac1f6 --- /dev/null +++ b/docs/site/layouts/page/single.html @@ -0,0 +1,6 @@ +{{ partial "header" . }} +
+

{{ .Title }}

+ {{ .Content }} +
+{{ partial "footer" }} \ No newline at end of file diff --git a/docs/site/layouts/partials/footer.html b/docs/site/layouts/partials/footer.html new file mode 100644 index 00000000..ec361af6 --- /dev/null +++ b/docs/site/layouts/partials/footer.html @@ -0,0 +1,10 @@ + +
+ +
+ + + + diff --git a/docs/site/layouts/partials/header.html b/docs/site/layouts/partials/header.html new file mode 100644 index 00000000..a117b052 --- /dev/null +++ b/docs/site/layouts/partials/header.html @@ -0,0 +1,42 @@ + + + + + {{ .Title }} + + + + + + + + + + + + {{ if .Params.thumbnail }} + + + {{ else }} + + + {{ end }} + + + +
+
+
+ + +
+
+
\ No newline at end of file diff --git a/docs/site/layouts/shortcodes/centered.html b/docs/site/layouts/shortcodes/centered.html new file mode 100644 index 00000000..333f3549 --- /dev/null +++ b/docs/site/layouts/shortcodes/centered.html @@ -0,0 +1,5 @@ +
+
 
+
{{ .Inner }}
+
+
\ No newline at end of file diff --git a/docs/site/layouts/shortcodes/github.html b/docs/site/layouts/shortcodes/github.html new file mode 100644 index 00000000..292fa560 --- /dev/null +++ b/docs/site/layouts/shortcodes/github.html @@ -0,0 +1,17 @@ +
    + {{ range .Page.Site.Data.github }} +
  • +
    + {{ dateFormat "Jan 2006" (substr .updated_at 0 10) }} +
    + +
    + {{ .description }} +
    +
    +
  • + {{ end }} +
+
\ No newline at end of file diff --git a/docs/site/layouts/shortcodes/half.html b/docs/site/layouts/shortcodes/half.html new file mode 100644 index 00000000..9a9caeac --- /dev/null +++ b/docs/site/layouts/shortcodes/half.html @@ -0,0 +1,4 @@ +
+
{{ .Inner }}
+
+
\ No newline at end of file diff --git a/docs/site/layouts/shortcodes/section.html b/docs/site/layouts/shortcodes/section.html new file mode 100644 index 00000000..ae856148 --- /dev/null +++ b/docs/site/layouts/shortcodes/section.html @@ -0,0 +1,3 @@ +
+ {{ .Inner }} +
\ No newline at end of file diff --git a/docs/site/static/static/base.css b/docs/site/static/static/base.css new file mode 100644 index 00000000..31b64f00 --- /dev/null +++ b/docs/site/static/static/base.css @@ -0,0 +1,190 @@ +/** +*** SIMPLE GRID +*** (C) ZACH COLE 2016 +**/ + + +/* UNIVERSAL */ + +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + left: 0; + top: 0; + font-size: 100%; +} + +.right { + text-align: right; +} + +.center { + text-align: center; + margin-left: auto; + margin-right: auto; +} + +.justify { + text-align: justify; +} + +/* ==== GRID SYSTEM ==== */ + +.container { + margin-left: auto; + margin-right: auto; +} + +.row { + position: relative; + width: 100%; +} + +.row [class^="col"] { + float: left; + margin: 0.5rem 2%; + min-height: 0.125rem; +} + +.col-1, +.col-2, +.col-3, +.col-4, +.col-5, +.col-6, +.col-7, +.col-8, +.col-9, +.col-10, +.col-11, +.col-12 { + width: 96%; +} + +.col-1-sm { + width: 4.33%; +} + +.col-2-sm { + width: 12.66%; +} + +.col-3-sm { + width: 21%; +} + +.col-4-sm { + width: 29.33%; +} + +.col-5-sm { + width: 37.66%; +} + +.col-6-sm { + width: 46%; +} + +.col-7-sm { + width: 54.33%; +} + +.col-8-sm { + width: 62.66%; +} + +.col-9-sm { + width: 71%; +} + +.col-10-sm { + width: 79.33%; +} + +.col-11-sm { + width: 87.66%; +} + +.col-12-sm { + width: 96%; +} + +.row::after { + content: ""; + display: table; + clear: both; +} + +.hidden-sm { + display: none; +} + +@media only screen and (min-width: 33.75em) { /* 540px */ + .container { + width: 80%; + } +} + +@media only screen and (min-width: 45em) { /* 720px */ + .col-1 { + width: 4.33%; + } + + .col-2 { + width: 12.66%; + } + + .col-3 { + width: 21%; + } + + .col-4 { + width: 29.33%; + } + + .col-5 { + width: 37.66%; + } + + .col-6 { + width: 46%; + } + + .col-7 { + width: 54.33%; + } + + .col-8 { + width: 62.66%; + } + + .col-9 { + width: 71%; + } + + .col-10 { + width: 79.33%; + } + + .col-11 { + width: 87.66%; + } + + .col-12 { + width: 96%; + } + + .hidden-sm { + display: block; + } +} + +@media only screen and (min-width: 60em) { /* 960px */ + .container { + width: 75%; + max-width: 60rem; + } +} diff --git a/docs/site/static/static/images/2022-07-31_19-07.png b/docs/site/static/static/images/2022-07-31_19-07.png new file mode 100644 index 00000000..47fc4836 Binary files /dev/null and b/docs/site/static/static/images/2022-07-31_19-07.png differ diff --git a/docs/site/static/static/images/2022-07-31_19-08.png b/docs/site/static/static/images/2022-07-31_19-08.png new file mode 100644 index 00000000..a9ead317 Binary files /dev/null and b/docs/site/static/static/images/2022-07-31_19-08.png differ diff --git a/docs/site/static/static/images/analytics.png b/docs/site/static/static/images/analytics.png new file mode 100644 index 00000000..bfa98d35 Binary files /dev/null and b/docs/site/static/static/images/analytics.png differ diff --git a/docs/site/static/static/images/favicon.png b/docs/site/static/static/images/favicon.png new file mode 100644 index 00000000..109f495b Binary files /dev/null and b/docs/site/static/static/images/favicon.png differ diff --git a/docs/site/static/static/images/listmonk.src.svg b/docs/site/static/static/images/listmonk.src.svg new file mode 100644 index 00000000..8441ed12 --- /dev/null +++ b/docs/site/static/static/images/listmonk.src.svg @@ -0,0 +1,233 @@ + + + + + + + + image/svg+xml + + + + + + + + + listmonk + + + + listmonk + listmonk + + + + + + + + listmonk + listmonk + + listmonk + + diff --git a/docs/site/static/static/images/lists.png b/docs/site/static/static/images/lists.png new file mode 100644 index 00000000..22b7f046 Binary files /dev/null and b/docs/site/static/static/images/lists.png differ diff --git a/docs/site/static/static/images/logo.png b/docs/site/static/static/images/logo.png new file mode 100644 index 00000000..64cf3c8a Binary files /dev/null and b/docs/site/static/static/images/logo.png differ diff --git a/docs/site/static/static/images/logo.svg b/docs/site/static/static/images/logo.svg new file mode 100644 index 00000000..d3d36e75 --- /dev/null +++ b/docs/site/static/static/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/site/static/static/images/media.png b/docs/site/static/static/images/media.png new file mode 100644 index 00000000..6ed66cf9 Binary files /dev/null and b/docs/site/static/static/images/media.png differ diff --git a/docs/site/static/static/images/messengers.png b/docs/site/static/static/images/messengers.png new file mode 100644 index 00000000..472f507e Binary files /dev/null and b/docs/site/static/static/images/messengers.png differ diff --git a/docs/site/static/static/images/performance.png b/docs/site/static/static/images/performance.png new file mode 100644 index 00000000..954dacd4 Binary files /dev/null and b/docs/site/static/static/images/performance.png differ diff --git a/docs/site/static/static/images/privacy.png b/docs/site/static/static/images/privacy.png new file mode 100644 index 00000000..bb358a77 Binary files /dev/null and b/docs/site/static/static/images/privacy.png differ diff --git a/docs/site/static/static/images/s1.png b/docs/site/static/static/images/s1.png new file mode 100644 index 00000000..e9f56a9f Binary files /dev/null and b/docs/site/static/static/images/s1.png differ diff --git a/docs/site/static/static/images/s2.png b/docs/site/static/static/images/s2.png new file mode 100644 index 00000000..e387e517 Binary files /dev/null and b/docs/site/static/static/images/s2.png differ diff --git a/docs/site/static/static/images/s2.svg b/docs/site/static/static/images/s2.svg new file mode 100644 index 00000000..6348ffce --- /dev/null +++ b/docs/site/static/static/images/s2.svg @@ -0,0 +1,83 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/docs/site/static/static/images/s3.png b/docs/site/static/static/images/s3.png new file mode 100644 index 00000000..b0aac8c7 Binary files /dev/null and b/docs/site/static/static/images/s3.png differ diff --git a/docs/site/static/static/images/s4.png b/docs/site/static/static/images/s4.png new file mode 100644 index 00000000..40a00353 Binary files /dev/null and b/docs/site/static/static/images/s4.png differ diff --git a/docs/site/static/static/images/smtp.png b/docs/site/static/static/images/smtp.png new file mode 100644 index 00000000..d4d01606 Binary files /dev/null and b/docs/site/static/static/images/smtp.png differ diff --git a/docs/site/static/static/images/splash.png b/docs/site/static/static/images/splash.png new file mode 100644 index 00000000..83df57be Binary files /dev/null and b/docs/site/static/static/images/splash.png differ diff --git a/docs/site/static/static/images/templating.png b/docs/site/static/static/images/templating.png new file mode 100644 index 00000000..5e97d955 Binary files /dev/null and b/docs/site/static/static/images/templating.png differ diff --git a/docs/site/static/static/images/thumbnail.png b/docs/site/static/static/images/thumbnail.png new file mode 100644 index 00000000..de8a32b6 Binary files /dev/null and b/docs/site/static/static/images/thumbnail.png differ diff --git a/docs/site/static/static/images/tx.png b/docs/site/static/static/images/tx.png new file mode 100644 index 00000000..a0ebd0c7 Binary files /dev/null and b/docs/site/static/static/images/tx.png differ diff --git a/docs/site/static/static/style.css b/docs/site/static/static/style.css new file mode 100644 index 00000000..fc33fdf6 --- /dev/null +++ b/docs/site/static/static/style.css @@ -0,0 +1,280 @@ +body { + background: #fdfdfd; + font-family: "Inter", "Helvetica Neue", "Segoe UI", sans-serif; + font-size: 17px; + font-weight: 400; + line-height: 30px; + color: #444; + overflow-x: hidden; +} + + +h1, +h2, +h3, +h4, +h5 { + font-weight: 600; + margin: 5px 0 15px 0; + color: #111; +} +h1 { + font-size: 2.5em; + line-height: 1.2em; + letter-spacing: -0.01em; +} +h2 { + font-size: 2em; + line-height: 1.4em; +} +h3 { + font-size: 1.6em; + line-height: 1.6em; +} +strong { + font-weight: 600; +} +section:not(:last-child) { + margin-bottom: 100px; +} +a { + color: #0055d4; + text-decoration: none; +} +a:hover { + color: #111; +} +::selection { + background: #111; + color: #fff; +} +pre { + background: #fafafa; + padding: 5px; + border-radius: 3px; + overflow-x: scroll; +} +code { + background: #fafafa; + padding: 5px; + border-radius: 3px; +} +img { + max-width: 100%; +} + +/* Helpers */ +.center { + text-align: center; +} +.small, code, pre { + font-size: 13px; + line-height: 20px; + color: #333; +} + +.box { + background: #fff; + border-radius: 6px; + border: 1px solid #e6e6e6; + box-shadow: 1px 1px 4px #e6e6e6; + padding: 30px; +} + +img.box { + display: inline-block; + padding: 0; +} + +figcaption { + color: #888; + font-size: 0.9em; +} + +.button { + background: #0055d4; + display: inline-block; + text-align: center; + font-weight: 600; + + color: #fff; + border-radius: 100px; + padding: 10px 15px; + min-width: 150px; +} +.button:hover { + background: #111; + color: #fff; +} +.notice { + background: #fafafa; + border-left: 4px solid #ddd; + color: #666; + padding: 5px 15px; +} + + +/* Layout */ +.container { + max-width: 1200px; + margin: 0 auto; +} +.header { + margin: 30px 0 60px 0; + text-align: left; +} + +.logo img { + width: 125px; + height: auto; +} + +nav { + text-align: right; +} + nav .item:not(:first-child) { + margin: 0 0 0 40px; + } + .github-btn { + min-width: 135px; + min-height: 38px; + float: right; + margin-left: 30px; + } + + +.splash .hero { + margin-bottom: 60px; +} + .splash .title { + max-width: 700px; + margin: 0 auto 30px auto; + font-size: 3em; + } + .splash .sub { + font-weight: 400; + color: #666; + } + .splash .confetti { + max-width: 1000px; + margin: 0 auto; + } + .splash .demo { + margin-top: 30px; + } + +.confetti { + position: relative; +} + .confetti .s1, .confetti .s2, .confetti .s3 { + position: absolute; + } + .confetti.light .s1, .confetti.light .s2, .confetti.light .s3 { + opacity: 0.30; + } + .confetti .s1 { + left: -35px; + top: 20%; + z-index: 10; + } + .confetti .s2 { + z-index: 30; + right: 20%; + top: -12px; + } + .confetti .s3 { + z-index: 30; + left: 15%; + bottom: 0; + } + .confetti .box { + position: relative; + z-index: 20; + } + +#download { + background: #f9f9f9; + padding: 160px 0 90px 0; + margin-top: -90px; +} + #download .install-steps li { + margin-bottom: 15px; + } + #download .download-links a:not(:last-child) { + display: inline-block; + margin-right: 15px; + } + #download .box { + min-height: 630px; + } + +.feature { +} + .feature h2 { + margin-bottom: 1em; + text-align: center; + } + .feature img { + margin-bottom: 1em; + } + .feature p { + margin-left: auto; + margin-right: auto; + max-width: 750px; + } + +.banner { + padding-top: 90px; +} + +.footer { + border-top: 1px solid #eee; + padding-top: 30px; + margin: 90px 0 30px 0; + color: #777; +} + +@media screen and (max-width: 720px) { + body { + /*font-size: 16px;*/ + } + .header { + margin-bottom: 15px; + text-align: center; + } + .header .columns { + margin-bottom: 10px; + } + + .box { + padding: 15px; + } + + .splash .title { + font-size: 2.1em; + line-height: 1.3em; + } + + .splash .sub { + font-size: 1.3em; + line-height: 1.5em; + } + + nav { + text-align: center; + } + + .github-btn { + float: none; + margin: 15px 0 0 0; + } + + section:not(:last-child) { + margin-bottom: 45px; + } +} +@media screen and (max-width: 540px) { + .container { + padding: 0 15px; + } +} \ No newline at end of file