Compare commits

...

No commits in common. "main" and "gh-pages" have entirely different histories.

1113 changed files with 200726 additions and 31711 deletions

View File

@ -1,2 +0,0 @@
[doc.extern-map.registries]
crates-io = "https://docs.rs/"

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
github: [epilys]

View File

@ -1,97 +0,0 @@
<style>
.rustdoc { flex-wrap: wrap; }
@media (prefers-color-scheme: light) {
:root {
--border-primary: #cdcdcd;
--border-secondary: #cdcdcd;
--a-normal-text: #034575;
--a-normal-underline: #bbb;
--a-visited-underline: #707070;
--a-hover-bg: #bfbfbf40;
--a-active-text: #c00;
--a-active-underline: #c00;
--accent-primary: #0085f2;
--accent-primary-engage: #0085f21a;
--accent-secondary: #0085f2;
--accent-tertiary: #0085f21a;
color-scheme: light;
}
}
@media (prefers-color-scheme: dark) {
:root {
--border-primary: #858585;
--border-secondary: #696969;
--a-normal-text: #4db4ff;
--a-normal-underline: #8b8b8b;
--a-visited-underline: #707070;
--a-hover-bg: #bfbfbf40;
--a-active-text: #c00;
--a-active-underline: #c00;
--accent-primary: #5e9eff;
--accent-primary-engage: #5e9eff1a;
--accent-secondary: #5e9eff;
--accent-tertiary: #0085f21a;
color-scheme: dark;
}
}
nav.main-nav {
padding: 0rem 1rem;
border: 0.1rem solid var(--border-secondary);
border-left: none;
border-right: none;
border-radius: 2px;
padding: 10px 14px 10px 10px;
margin-bottom: 10px;
/*! width: 100%; */
height: max-content;
width: 100vw;
}
nav.main-nav * {
padding: 0;
margin: 0;
}
nav.main-nav > ul {
display: flex;
flex-wrap: wrap;
row-gap: 1rem;
list-style: none;
}
nav.main-nav > ul > li > a {
padding: 1rem;
}
nav.main-nav a[href] {
text-decoration: underline;
text-decoration-color: currentcolor;
color: #034575;
color: var(--a-normal-text);
text-decoration-color: #707070;
text-decoration-color: var(--accent-secondary);
text-decoration-skip-ink: none;
font-synthesis: none;
font-feature-settings: "onum" 1;
text-rendering: optimizeLegibility;
font-family: -apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif;
font-size: 1.125em;
}
nav.main-nav > ul > li > a:hover {
outline: 0.1rem solid;
outline-offset: -0.5rem;
}
a[href]:focus, a[href]:hover {
text-decoration-thickness: 2px;
text-decoration-skip-ink: none;
}
</style>
<nav class="main-nav">
<ul>
<li><a href="https://git.meli.delivery/meli/mailpot">Gitea Repository</a></li>
<li><a href="https://github.com/meli/mailpot">Github Repository</a></li>
<li><a href="https://lists.meli.delivery/">Official Instance</a></li>
<li><a href="https://meli.github.io/mailpot/lists/2/">Static Demo</a></li>
</ul>
</nav>

5
.github/grcov.yml vendored
View File

@ -1,5 +0,0 @@
ignore-not-existing: true
branch: true
output-type: html
binary-path: ./target/debug/
output-path: ./coverage/

View File

@ -1,96 +0,0 @@
name: Build release binary
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
on:
workflow_dispatch:
push:
tags:
- v*
jobs:
build:
name: Build on ${{ matrix.build }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
build: [linux-amd64, ]
include:
- build: linux-amd64
os: ubuntu-latest
rust: stable
artifact_name: 'mailpot-linux-amd64'
target: x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@v2
- id: cache-sqlite3-bin
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .
rm -rf sqlite-tools-linux-x86-3420000*
echo "SQLITE_BIN=$(pwd)/sqlite3" >> $GITHUB_ENV
- id: cache-rustup
name: Cache Rust toolchain
uses: actions/cache@v3
with:
path: ~/.rustup
key: toolchain-${{ matrix.os }}-${{ matrix.rust }}
- if: ${{ steps.cache-rustup.outputs.cache-hit != 'true' }}
name: Install Rust ${{ matrix.rust }}
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ matrix.rust }}
target: ${{ matrix.target }}
override: true
- name: Configure cargo data directory
# After this point, all cargo registry and crate data is stored in
# $GITHUB_WORKSPACE/.cargo_home. This allows us to cache only the files
# that are needed during the build process. Additionally, this works
# around a bug in the 'cache' action that causes directories outside of
# the workspace dir to be saved/restored incorrectly.
run: echo "CARGO_HOME=$(pwd)/.cargo_home" >> $GITHUB_ENV
- id: cache-cargo
name: Cache cargo configuration and installations
uses: actions/cache@v3
with:
path: ${{ env.CARGO_HOME }}
key: cargo-${{ matrix.os }}-${{ matrix.rust }}
- if: ${{ steps.cache-cargo.outputs.cache-hit != 'true' }} && matrix.target
name: Setup Rust target
run: |
mkdir -p "${{ env.CARGO_HOME }}"
cat << EOF > "${{ env.CARGO_HOME }}"/config.toml
[build]
target = "${{ matrix.target }}"
EOF
- name: Build binary
run: |
cargo build --release --bin mpot --bin mpot-gen --bin mpot-web -p mailpot-cli -p mailpot-archives -p mailpot-web
mkdir artifacts
mv target/*/release/* target/ || true
mv target/release/* target/ || true
mv target/mpot target/mpot-web target/mpot-gen artifacts/
- name: Upload Artifacts
uses: actions/upload-artifact@v2
with:
name: ${{ matrix.artifact_name }}
path: artifacts
if-no-files-found: error
retention-days: 7

View File

@ -1,114 +0,0 @@
name: Code coverage
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
on:
workflow_dispatch:
workflow_run:
workflows: [Tests]
types: [completed]
branches: [main]
jobs:
on-success:
runs-on: ubuntu-latest #if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@v1
- id: cache-sqlite3-bin
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .
rm -rf sqlite-tools-linux-x86-3420000*
echo "SQLITE_BIN=$(pwd)/sqlite3" >> $GITHUB_ENV
- id: cache-rustup
name: Cache Rust toolchain
uses: actions/cache@v3
with:
path: ~/.rustup
key: toolchain-grcov
- id: cache-cargo
name: Cache Cargo
uses: actions/cache@v3
with:
path: ~/.cargo
key: cargo-grcov
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
- uses: actions-rs/cargo@v1
with:
command: test
args: --all --all-features --no-fail-fast
env:
CARGO_INCREMENTAL: '0'
RUSTFLAGS: '-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests -Cinstrument-coverage'
RUSTDOCFLAGS: '-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests -Cinstrument-coverage'
- uses: actions-rs/grcov@v0.1
with:
config: .github/grcov.yml
- name: Upload report artifacts
uses: actions/upload-artifact@v3
with:
name: report
path: coverage
deploy:
# Add a dependency to the build job
needs: on-success
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
# Deploy to the github-pages environment
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
# Specify runner + deployment step
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/checkout@v3
with:
ref: 'gh-pages'
token: ${{ secrets.GRCOVGHPAGES }}
- name: Download coverage data
id: download
uses: actions/download-artifact@v3
with:
name: report
path: coverage
- name: 'Echo download path'
run: echo ${{steps.download.outputs.download-path}}
- name: Display structure of downloaded files
run: ls -R
- name: Push
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git show-ref
git add coverage
git commit -m "Update grcov report"
git show-ref
git branch --verbose
git remote set-url origin "https://${{github.actor}}:${{ secrets.GRCOVGHPAGES }}@github.com/${{github.repository}}.git"
git push

View File

@ -1,100 +0,0 @@
name: Build rustdoc for Github Pages
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
on:
workflow_dispatch:
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- id: cache-sqlite3-bin
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .
rm -rf sqlite-tools-linux-x86-3420000*
echo "SQLITE_BIN=$(pwd)/sqlite3" >> $GITHUB_ENV
- id: cache-rustup
name: Cache Rust toolchain
uses: actions/cache@v3
with:
path: ~/.rustup
key: toolchain-grcov
- id: cache-cargo
name: Cache Cargo
uses: actions/cache@v3
with:
path: ~/.cargo
key: cargo-grcov
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
- name: Make rustdocs
run: |
make rustdoc-nightly || make rustdoc
rm -rf docs
ls -R
mv target/doc docs
- name: Upload report artifacts
uses: actions/upload-artifact@v3
with:
name: docs
path: docs
deploy-docs:
needs: build-docs
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/checkout@v3
with:
ref: 'gh-pages'
token: ${{ secrets.GRCOVGHPAGES }}
- name: Download docs
id: download
uses: actions/download-artifact@v3
with:
name: docs
path: docs
- name: 'Echo download path'
run: echo ${{steps.download.outputs.download-path}}
- name: Display structure of downloaded files
run: ls -R
- name: Push
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git show-ref
git add docs
git commit -m "Update rustdoc"
git show-ref
git branch --verbose
git remote set-url origin "https://${{github.actor}}:${{ secrets.GRCOVGHPAGES }}@github.com/${{github.repository}}.git"
git push

View File

@ -1,114 +0,0 @@
name: Tests
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
on:
workflow_dispatch:
push:
branches:
- '**'
paths:
- 'core/src/**'
- 'core/tests/**'
- 'core/Cargo.toml'
- 'Cargo.lock'
jobs:
test:
name: Test on ${{ matrix.build }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
build: [linux-amd64, ]
include:
- build: linux-amd64
os: ubuntu-latest
rust: stable
target: x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@v2
- id: cache-sqlite3-bin
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .
rm -rf sqlite-tools-linux-x86-3420000*
echo "SQLITE_BIN=$(pwd)/sqlite3" >> $GITHUB_ENV
- id: cache-rustup
name: Cache Rust toolchain
uses: actions/cache@v3
with:
path: ~/.rustup
key: toolchain-${{ matrix.os }}-${{ matrix.rust }}
- if: ${{ steps.cache-rustup.outputs.cache-hit != 'true' }}
name: Install Rust ${{ matrix.rust }}
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ matrix.rust }}
components: clippy, rustfmt
target: ${{ matrix.target }}
override: true
- name: Configure cargo data directory
# After this point, all cargo registry and crate data is stored in
# $GITHUB_WORKSPACE/.cargo_home. This allows us to cache only the files
# that are needed during the build process. Additionally, this works
# around a bug in the 'cache' action that causes directories outside of
# the workspace dir to be saved/restored incorrectly.
run: echo "CARGO_HOME=$(pwd)/.cargo_home" >> $GITHUB_ENV
- id: cache-cargo
name: Cache cargo configuration and installations
uses: actions/cache@v3
with:
path: ${{ env.CARGO_HOME }}
key: cargo-${{ matrix.os }}-${{ matrix.rust }}
- if: ${{ steps.cache-cargo.outputs.cache-hit != 'true' }} && matrix.target
name: Setup Rust target
run: |
mkdir -p "${{ env.CARGO_HOME }}"
cat << EOF > "${{ env.CARGO_HOME }}"/config.toml
[build]
target = "${{ matrix.target }}"
EOF
- if: ${{ steps.cache-cargo.outputs.cache-hit != 'true' }} && matrix.target
name: Add lint dependencies
run: |
cargo install --target "${{ matrix.target }}" cargo-sort
- name: cargo-check
run: |
cargo check --all-features --all --tests --examples --benches --bins
- name: cargo test
if: success() || failure() # always run even if other steps fail, except when cancelled <https://stackoverflow.com/questions/58858429/how-to-run-a-github-actions-step-even-if-the-previous-step-fails-while-still-f>
run: |
cargo test --all --no-fail-fast --all-features
- name: cargo-sort
if: success() || failure()
run: |
cargo sort --check
- name: rustfmt
if: success() || failure()
run: |
cargo fmt --check --all
- name: clippy
if: success() || failure()
run: |
cargo clippy --no-deps --all-features --all --tests --examples --benches --bins
- name: rustdoc
if: success() || failure()
run: |
make rustdoc

View File

@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
manos@pitsidianak.is.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@ -1,102 +0,0 @@
# Contributing to mailpot
Contributions are welcome and encouraged.
They can be anything from spelling corrections, art, documentation, or source code fixes & additions.
If a source code contribution is correct, functional and follows the code style and feature goals of the rest of the project, it will be merged.
**Table of contents**:
- [Important links](#important-links)
- [Developing environment](#developing-environment)
- [Testing](#testing)
- [How to submit changes](#how-to-submit-changes)
- [Choosing what to work on](#choosing-what-to-work-on)
- [How to request an enhancement, new features](#how-to-request-an-enhancement-new-features)
- [Style Guide / Coding conventions](#style-guide--coding-conventions)
- [Specific questions and answers](#specific-questions-and-answers)
- [How do I include new images / icons?](#how-do-i-include-new-images--icons)
## Important links
- Main repository: <https://git.meli.delivery/meli/mailpot>
- Bug/Issue tracker: <https://git.meli.delivery/meli/mailpot/issues>
- Mailing list: <https://lists.meli.delivery/list/mailpot-general/>
To privately contact the repository's owner, check their commit history for their e-mail address.
<sup><sub><a href="#contributing-to-mailpot">back to top</a></sub></sup>
## Developing environment
You will need a UNIX-like operating system that is supported by Rust.
You can install rust and cargo with the [`rustup`](https://rustup.rs) tool.
<sup><sub><a href="#contributing-to-mailpot">back to top</a></sub></sup>
## Testing
All tests can be executed with `cargo`.
Run
```shell
cargo test --all --no-fail-fast --all-features
```
to run all tests.
<sup><sub><a href="#contributing-to-mailpot">back to top</a></sub></sup>
## How to submit changes
Use gitea's PR functionality.
Alternatively, submit patches to the mailing list.
<sup><sub><a href="#contributing-to-mailpot">back to top</a></sub></sup>
## Choosing what to work on
You can find some tasks in the bug tracker.
Additionally, tasks are annotated inside the source code with the keywords `FIXME`, `TODO` and others.
For a list of all tags search for `[tag:`.
For a list of all references search for `[ref:`.
To find tag references you can use a text search tool of your choice such as `grep`, `ripgrep` or others.
The CLI tool `tagref` can also be used:
```shell
/path/to/mailpot $ tagref list-refs
[ref:FIXME] @ ./src/module.rs:106
[ref:FIXME] @ ./src/module.rs:867
[ref:FIXME] @ ./src/module.rs:30
[ref:TODO] @ ./src/where.rs:411
...
```
You can of course filter or sort them by tag:
```shell
/path/to/mailpot $ tagref list-refs | grep TODO
...
/path/to/mailpot $ tagref list-refs | sort -u
...
```
<sup><sub><a href="#contributing-to-mailpot">back to top</a></sub></sup>
## How to request an enhancement, new features
Simply open a new issue on the bug tracker or post on the mailing list.
<sup><sub><a href="#contributing-to-mailpot">back to top</a></sub></sup>
## Style Guide / Coding conventions
All Rust code must be formatted by `rustfmt`, and pass clippy lints.
```shell
cargo check --all-features --all --tests --examples --benches --bins
cargo +nightly fmt --all || cargo fmt --all
cargo clippy --no-deps --all-features --all --tests --examples --benches --bins
djhtml -i web/src/templates/* || printf "djhtml binary not found in PATH.\n"
```
<sup><sub><a href="#contributing-to-mailpot">back to top</a></sub></sup>

4667
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +0,0 @@
[workspace]
resolver = "2"
members = [
"archive-http",
"cli",
"core",
"mailpot-tests",
"rest-http",
"web",
]
[profile.release]
lto = "fat"
opt-level = "z"
codegen-units = 1
split-debuginfo = "unpacked"

View File

@ -1,46 +0,0 @@
.POSIX:
.SUFFIXES:
CARGOBIN = cargo
CARGOSORTBIN = cargo-sort
DJHTMLBIN = djhtml
BLACKBIN = black
PRINTF = /usr/bin/printf
HTML_FILES := $(shell find web/src/templates -type f -print0 | tr '\0' ' ')
PY_FILES := $(shell find . -type f -name '*.py' -print0 | tr '\0' ' ')
.PHONY: check
check:
@$(CARGOBIN) check --all-features --all --tests --examples --benches --bins
.PHONY: fmt
fmt:
@$(CARGOBIN) +nightly fmt --all || $(CARGOBIN) fmt --all
@OUT=$$($(CARGOSORTBIN) -w 2>&1) || $(PRINTF) "ERROR: %s cargo-sort failed or binary not found in PATH.\n" "$$OUT"
@OUT=$$($(DJHTMLBIN) $(HTML_FILES) 2>&1) || $(PRINTF) "ERROR: %s djhtml failed or binary not found in PATH.\n" "$$OUT"
@OUT=$$($(BLACKBIN) -q $(PY_FILES) 2>&1) || $(PRINTF) "ERROR: %s black failed or binary not found in PATH.\n" "$$OUT"
.PHONY: lint
lint:
@$(CARGOBIN) clippy --no-deps --all-features --all --tests --examples --benches --bins
.PHONY: test
test: check lint
@$(CARGOBIN) nextest run --all --no-fail-fast --all-features
.PHONY: rustdoc
rustdoc:
@RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" $(CARGOBIN) doc --workspace --all-features --no-deps --document-private-items
.PHONY: rustdoc-open
rustdoc-open:
@RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" $(CARGOBIN) doc --workspace --all-features --no-deps --document-private-items --open
.PHONY: rustdoc-nightly
rustdoc-nightly:
@RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" $(CARGOBIN) +nightly doc -Zrustdoc-map -Z rustdoc-scrape-examples --workspace --all-features --no-deps --document-private-items
.PHONY: rustdoc-nightly-open
rustdoc-nightly-open:
@RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" $(CARGOBIN) +nightly doc -Zrustdoc-map -Z rustdoc-scrape-examples --workspace --all-features --no-deps --document-private-items --open

329
README.md
View File

@ -1,329 +0,0 @@
# mailpot - mailing list manager
[![Latest Version]][crates.io]&nbsp;[![Coverage]][grcov-rport]&nbsp;[![docs.rs]][rustdoc]&nbsp;![Top Language]&nbsp;![License]
[Latest Version]: https://img.shields.io/crates/v/mailpot.svg?color=white
[crates.io]: https://crates.io/crates/mailpot
[Top Language]: https://img.shields.io/github/languages/top/meli/mailpot?color=white&logo=rust&logoColor=black
[License]: https://img.shields.io/github/license/meli/mailpot?color=white
[docs.rs]: https://img.shields.io/docsrs/mailpot?color=white
[rustdoc]: https://meli.github.io/mailpot/docs/mailpot/
[Coverage]: https://img.shields.io/endpoint?color=white&url=https://meli.github.io/mailpot/coverage/coverage.json
[grcov-rport]: https://meli.github.io/mailpot/coverage/
- Official hosted instance of `mailpot-web` crate: <https://lists.meli.delivery/>
- Rendered rustdoc: <https://meli.github.io/mailpot/docs/mailpot/>
- CLI manpage: [`mpot.1`](./docs/mpot.1) [Rendered](https://git.meli.delivery/meli/mailpot/src/branch/main/docs/mpot.1)
| Interested in contributing? Consult [`CONTRIBUTING.md`](./CONTRIBUTING.md). |
| --- |
## crates:
- `core` the library
- `cli` a command line tool to manage lists
- `web` an `axum` based web server capable of serving archives and authenticating list owners and members
- `archive-http` static web archive generation or with a dynamic http server
- `rest-http` a REST http server to manage lists
## Features
- easy setup
- extensible through Rust API as a [library](./core)
- basic management through [CLI tool](./cli/)
- optional lightweight web archiver ([static](./archive-http/) and [dynamic](./web/))
- useful for both **newsletters**, **communities** and for static **article comments**
## Roadmap
- extensible through HTTP REST API as an HTTP server, with webhooks
## Initial setup
Create a configuration file and a database:
```shell
$ mkdir -p /home/user/.config/mailpot
$ export MPOT_CONFIG=/home/user/.config/mailpot/config.toml
$ cargo run --bin mpot -- sample-config > "$MPOT_CONFIG"
$ # edit config and set database path e.g. "/home/user/.local/share/mailpot/mpot.db"
$ cargo run --bin mpot -- -c "$MPOT_CONFIG" list-lists
No lists found.
```
This creates the database file in the configuration file as if you executed the following:
```shell
$ sqlite3 /home/user/.local/share/mailpot/mpot.db < ./core/src/schema.sql
```
## Examples
```text
% mpot help
GNU Affero version 3 or later <https://www.gnu.org/licenses/>
Tool for mailpot mailing list management.
Usage: mpot [OPTIONS] <COMMAND>
Commands:
sample-config
Prints a sample config file to STDOUT
dump-database
Dumps database data to STDOUT
list-lists
Lists all registered mailing lists
list
Mailing list management
create-list
Create new list
post
Post message from STDIN to list
flush-queue
Flush outgoing e-mail queue
error-queue
Mail that has not been handled properly end up in the error queue
queue
Mail that has not been handled properly end up in the error queue
import-maildir
Import a maildir folder into an existing list
update-postfix-config
Update postfix maps and master.cf (probably needs root permissions)
print-postfix-config
Print postfix maps and master.cf entry to STDOUT
accounts
All Accounts
account-info
Account info
add-account
Add account
remove-account
Remove account
update-account
Update account info
repair
Show and fix possible data mistakes or inconsistencies
help
Print this message or the help of the given subcommand(s)
Options:
-d, --debug
Print logs
-c, --config <CONFIG>
Configuration file to use
-q, --quiet
Silence all output
-v, --verbose...
Verbose mode (-v, -vv, -vvv, etc)
-t, --ts <TS>
Debug log timestamp (sec, ms, ns, none)
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
```
### Receiving mail
```shell
$ cat list-request.eml | cargo run --bin mpot -- -vvvvvv post --dry-run
```
<details><summary>output</summary>
```shell
TRACE - Received envelope to post: Envelope {
Subject: "unsubscribe",
Date: "Tue, 04 Aug 2020 14:10:13 +0300",
From: [
Address::Mailbox {
display_name: "Mxxxx Pxxxxxxxxxxxx",
address_spec: "exxxxx@localhost",
},
],
To: [
Address::Mailbox {
display_name: "",
address_spec: "test-announce+request@localhost",
},
],
Message-ID: "<ejduu.fddf8sgen4j7@localhost>",
In-Reply-To: None,
References: None,
Hash: 12581897380059220314,
}
TRACE - unsubscribe action for addresses [Address::Mailbox { display_name: "Mxxxx Pxxxxxxxxxxxx", address_spec: "exxxxx@localhost" }] in list [#2 test-announce] test announcements <test-announce@localhost>
TRACE - Is post related to list [#1 test] Test list <test@localhost>? false
```
</details>
```shell
$ cat list-post.eml | cargo run --bin mpot -- -vvvvvv post --dry-run
```
<details><summary>output</summary>
```shell
TRACE - Received envelope to post: Envelope {
Subject: "[test-announce] new test releases",
Date: "Tue, 04 Aug 2020 14:10:13 +0300",
From: [
Address::Mailbox {
display_name: "Mxxxx Pxxxxxxxxxxxx",
address_spec: "exxxxx@localhost",
},
],
To: [
Address::Mailbox {
display_name: "",
address_spec: "test-announce@localhost",
},
],
Message-ID: "<ejduu.sddf8sgen4j7@localhost>",
In-Reply-To: None,
References: None,
Hash: 10220641455578979007,
}
TRACE - Is post related to list [#1 test] Test list <test@localhost>? false
TRACE - Is post related to list [#2 test-announce] test announcements <test-announce@localhost>? true
TRACE - Examining list "test announcements" <test-announce@localhost>
TRACE - List subscriptions [
ListSubscription {
list: 2,
address: "exxxxx@localhost",
name: None,
digest: false,
hide_address: false,
receive_duplicates: false,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
]
TRACE - Running FixCRLF filter
TRACE - Running PostRightsCheck filter
TRACE - Running AddListHeaders filter
TRACE - Running FinalizeRecipients filter
TRACE - examining subscription ListSubscription { list: 2, address: "exxxxx@localhost", name: None, digest: false, hide_address: false, receive_duplicates: false, receive_own_posts: true, receive_confirmation: true, enabled: true }
TRACE - subscription is submitter
TRACE - subscription gets copy
TRACE - result Ok(
Post {
list: MailingList {
pk: 2,
name: "test announcements",
id: "test-announce",
address: "test-announce@localhost",
description: None,
archive_url: None,
},
from: Address::Mailbox {
display_name: "Mxxxx Pxxxxxxxxxxxx",
address_spec: "exxxxx@localhost",
},
subscriptions: 1,
bytes: 851,
policy: None,
to: [
Address::Mailbox {
display_name: "",
address_spec: "test-announce@localhost",
},
],
action: Accept {
recipients: [
Address::Mailbox {
display_name: "",
address_spec: "exxxxx@localhost",
},
],
digests: [],
},
},
)
```
</details>
## Using `mailpot` as a library
```rust
use mailpot::{models::*, *};
use tempfile::TempDir;
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
administrators: vec!["myaddress@example.com".to_string()],
};
let db = Connection::open_or_create_db(config)?.trusted();
// Create a new mailing list
let list_pk = db.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?.pk;
db.set_list_post_policy(
PostPolicy {
pk: 0,
list: list_pk,
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
},
)?;
// Drop privileges; we can only process new e-mail and modify subscriptions from now on.
let mut db = db.untrusted();
assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
// Process a subscription request e-mail
let subscribe_bytes = b"From: Name <user@example.com>
To: <foo-chat+subscribe@example.com>
Subject: subscribe
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <1@example.com>
";
let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
// Process a post
let post_bytes = b"From: Name <user@example.com>
To: <foo-chat@example.com>
Subject: my first post
Date: Thu, 29 Oct 2020 14:01:09 +0000
Message-ID: <2@example.com>
Hello
";
let envelope =
melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
db.post(&envelope, post_bytes, /* dry_run */ false)?;
assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
# Ok::<(), Error>(())
```

View File

@ -1,25 +0,0 @@
[package]
name = "mailpot-archives"
version = "0.1.1"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2021"
license = "LICENSE"
readme = "README.md"
description = "mailing list manager"
repository = "https://github.com/meli/mailpot"
keywords = ["mail", "mailing-lists"]
categories = ["email"]
default-run = "mpot-archives"
[[bin]]
name = "mpot-archives"
path = "src/main.rs"
[dependencies]
chrono = { version = "^0.4" }
lazy_static = "^1.4"
mailpot = { version = "^0.1", path = "../core" }
minijinja = { version = "0.31.0", features = ["source", ] }
percent-encoding = { version = "^2.1", optional = true }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"

View File

@ -1,12 +0,0 @@
# mailpot REST http server
```shell
cargo run --bin mpot-archives
```
## generate static files
```shell
# mpot-gen CONF_FILE OUTPUT_DIR OPTIONAL_ROOT_URL_PREFIX
cargo run --bin mpot-gen -- ../conf.toml ./out/ "/mailpot"
```

View File

@ -1 +0,0 @@
../rustfmt.toml

View File

@ -1,244 +0,0 @@
// MIT License
//
// Copyright (c) 2021 sadnessOjisan
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
use chrono::*;
#[allow(dead_code)]
/// Generate a calendar view of the given date's month.
///
/// Each vector element is an array of seven numbers representing weeks
/// (starting on Sundays), and each value is the numeric date.
/// A value of zero means a date that not exists in the current month.
///
/// # Examples
/// ```
/// use chrono::*;
/// use mailpot_archives::cal::calendarize;
///
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
/// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
/// println!("{:?}", calendarize(date));
/// // [0, 0, 0, 0, 0, 1, 2],
/// // [3, 4, 5, 6, 7, 8, 9],
/// // [10, 11, 12, 13, 14, 15, 16],
/// // [17, 18, 19, 20, 21, 22, 23],
/// // [24, 25, 26, 27, 28, 29, 30],
/// // [31, 0, 0, 0, 0, 0, 0]
/// ```
pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
calendarize_with_offset(date, 0)
}
/// Generate a calendar view of the given date's month and offset.
///
/// Each vector element is an array of seven numbers representing weeks
/// (starting on Sundays), and each value is the numeric date.
/// A value of zero means a date that not exists in the current month.
///
/// Offset means the number of days from sunday.
/// For example, 1 means monday, 6 means saturday.
///
/// # Examples
/// ```
/// use chrono::*;
/// use mailpot_archives::cal::calendarize_with_offset;
///
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
/// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
/// println!("{:?}", calendarize_with_offset(date, 1));
/// // [0, 0, 0, 0, 1, 2, 3],
/// // [4, 5, 6, 7, 8, 9, 10],
/// // [11, 12, 13, 14, 15, 16, 17],
/// // [18, 19, 20, 21, 22, 23, 24],
/// // [25, 26, 27, 28, 29, 30, 0],
/// ```
pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
let year = date.year();
let month = date.month();
let num_days_from_sunday = NaiveDate::from_ymd_opt(year, month, 1)
.unwrap()
.weekday()
.num_days_from_sunday();
let mut first_date_day;
if num_days_from_sunday < offset {
first_date_day = num_days_from_sunday + (7 - offset);
} else {
first_date_day = num_days_from_sunday - offset;
}
let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
.unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
.pred_opt()
.unwrap()
.day();
let mut date: u32 = 0;
while date < end_date {
let mut week: [u32; 7] = [0; 7];
for day in first_date_day..7 {
date += 1;
week[day as usize] = date;
if date >= end_date {
break;
}
}
first_date_day = 0;
monthly_calendar.push(week);
}
monthly_calendar
}
#[test]
fn january() {
let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 1, 2],
[3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16],
[17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30],
[31, 0, 0, 0, 0, 0, 0]
],
actual
);
}
#[test]
// Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
fn with_offset_from_sunday() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 0);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 1, 2],
[3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16],
[17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30],
],
actual
);
}
#[test]
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
fn with_offset_from_monday() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 1);
assert_eq!(
vec![
[0, 0, 0, 0, 1, 2, 3],
[4, 5, 6, 7, 8, 9, 10],
[11, 12, 13, 14, 15, 16, 17],
[18, 19, 20, 21, 22, 23, 24],
[25, 26, 27, 28, 29, 30, 0],
],
actual
);
}
#[test]
// Week = [Sat, Sun, Mon, Tue, Wed, Thu, Fri]
fn with_offset_from_saturday() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 6);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 0, 1],
[2, 3, 4, 5, 6, 7, 8],
[9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22],
[23, 24, 25, 26, 27, 28, 29],
[30, 0, 0, 0, 0, 0, 0]
],
actual
);
}
#[test]
// Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
fn with_offset_from_sunday_with7() {
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
let actual = calendarize_with_offset(date, 7);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 1, 2],
[3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16],
[17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30],
],
actual
);
}
#[test]
fn april() {
let date = NaiveDate::parse_from_str("2021-04-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 0, 0, 0, 1, 2, 3],
[4, 5, 6, 7, 8, 9, 10],
[11, 12, 13, 14, 15, 16, 17],
[18, 19, 20, 21, 22, 23, 24],
[25, 26, 27, 28, 29, 30, 0]
],
actual
);
}
#[test]
fn uruudoshi() {
let date = NaiveDate::parse_from_str("2020-02-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 0, 0, 0, 0, 0, 1],
[2, 3, 4, 5, 6, 7, 8],
[9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22],
[23, 24, 25, 26, 27, 28, 29]
],
actual
);
}
#[test]
fn uruwanaidoshi() {
let date = NaiveDate::parse_from_str("2021-02-02", "%Y-%m-%d").unwrap();
let actual = calendarize(date);
assert_eq!(
vec![
[0, 1, 2, 3, 4, 5, 6],
[7, 8, 9, 10, 11, 12, 13],
[14, 15, 16, 17, 18, 19, 20],
[21, 22, 23, 24, 25, 26, 27],
[28, 0, 0, 0, 0, 0, 0]
],
actual
);
}

View File

@ -1,259 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{fs::OpenOptions, io::Write};
use mailpot::*;
use mailpot_archives::utils::*;
use minijinja::value::Value;
fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().collect::<Vec<_>>();
let Some(config_path) = args
.get(1) else {
return Err("Expected configuration file path as first argument.".into());
};
let Some(output_path) = args
.get(2) else {
return Err("Expected output dir path as second argument.".into());
};
let root_url_prefix = args.get(3).cloned().unwrap_or_default();
let output_path = std::path::Path::new(&output_path);
if output_path.exists() && !output_path.is_dir() {
return Err("Output path is not a directory.".into());
}
std::fs::create_dir_all(&output_path.join("lists"))?;
std::fs::create_dir_all(&output_path.join("list"))?;
let conf = Configuration::from_file(config_path)
.map_err(|err| format!("Could not load config {config_path}: {err}"))?;
let db = Connection::open_db(conf).map_err(|err| format!("Couldn't open db: {err}"))?;
let lists_values = db.lists()?;
{
//index.html
let lists = lists_values
.iter()
.map(|list| {
let months = db.months(list.pk).unwrap();
let posts = db.list_posts(list.pk, None).unwrap();
minijinja::context! {
title => &list.name,
posts => &posts,
months => &months,
body => &list.description.as_deref().unwrap_or_default(),
root_prefix => &root_url_prefix,
list => Value::from_object(MailingList::from(list.clone())),
}
})
.collect::<Vec<_>>();
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&output_path.join("index.html"))?;
let crumbs = vec![Crumb {
label: "Lists".into(),
url: format!("{root_url_prefix}/").into(),
}];
let context = minijinja::context! {
title => "mailing list archive",
description => "",
lists => &lists,
root_prefix => &root_url_prefix,
crumbs => crumbs,
};
file.write_all(
TEMPLATES
.get_template("lists.html")?
.render(context)?
.as_bytes(),
)?;
}
let mut lists_path = output_path.to_path_buf();
for list in &lists_values {
lists_path.push("lists");
lists_path.push(list.pk.to_string());
std::fs::create_dir_all(&lists_path)?;
lists_path.push("index.html");
let list = db.list(list.pk)?.unwrap();
let post_policy = db.list_post_policy(list.pk)?;
let months = db.months(list.pk)?;
let posts = db.list_posts(list.pk, None)?;
let mut hist = months
.iter()
.map(|m| (m.to_string(), [0usize; 31]))
.collect::<std::collections::HashMap<String, [usize; 31]>>();
let posts_ctx = posts
.iter()
.map(|post| {
//2019-07-14T14:21:02
if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
}
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.expect("Could not parse mail");
let mut msg_id = &post.message_id[1..];
msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
let subject = envelope.subject();
let mut subject_ref = subject.trim();
if subject_ref.starts_with('[')
&& subject_ref[1..].starts_with(&list.id)
&& subject_ref[1 + list.id.len()..].starts_with(']')
{
subject_ref = subject_ref[2 + list.id.len()..].trim();
}
minijinja::context! {
pk => post.pk,
list => post.list,
subject => subject_ref,
address=> post.address,
message_id => msg_id,
message => post.message,
timestamp => post.timestamp,
datetime => post.datetime,
root_prefix => &root_url_prefix,
}
})
.collect::<Vec<_>>();
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: format!("{root_url_prefix}/").into(),
},
Crumb {
label: list.name.clone().into(),
url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
},
];
let context = minijinja::context! {
title=> &list.name,
description=> &list.description,
post_policy=> &post_policy,
preamble => true,
months=> &months,
hists => &hist,
posts=> posts_ctx,
body=>&list.description.clone().unwrap_or_default(),
root_prefix => &root_url_prefix,
list => Value::from_object(MailingList::from(list.clone())),
crumbs => crumbs,
};
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&lists_path)
.map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
file.write_all(
TEMPLATES
.get_template("list.html")?
.render(context)?
.as_bytes(),
)?;
lists_path.pop();
lists_path.pop();
lists_path.pop();
lists_path.push("list");
lists_path.push(list.pk.to_string());
std::fs::create_dir_all(&lists_path)?;
for post in posts {
let mut msg_id = &post.message_id[1..];
msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
lists_path.push(format!("{msg_id}.html"));
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.map_err(|err| format!("Could not parse mail {}: {err}", post.message_id))?;
let body = envelope.body_bytes(post.message.as_slice());
let body_text = body.text();
let subject = envelope.subject();
let mut subject_ref = subject.trim();
if subject_ref.starts_with('[')
&& subject_ref[1..].starts_with(&list.id)
&& subject_ref[1 + list.id.len()..].starts_with(']')
{
subject_ref = subject_ref[2 + list.id.len()..].trim();
}
let mut message_id = &post.message_id[1..];
message_id = &message_id[..message_id.len().saturating_sub(1)];
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: format!("{root_url_prefix}/").into(),
},
Crumb {
label: list.name.clone().into(),
url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
},
Crumb {
label: subject_ref.to_string().into(),
url: format!("{root_url_prefix}/lists/{}/{message_id}.html/", list.pk).into(),
},
];
let context = minijinja::context! {
title => &list.name,
list => &list,
post => &post,
posts => &posts_ctx,
body => &body_text,
from => &envelope.field_from_to_string(),
date => &envelope.date_as_str(),
to => &envelope.field_to_to_string(),
subject => &envelope.subject(),
trimmed_subject => subject_ref,
in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
references => &envelope .references() .into_iter() .map(|m| m.to_string().as_str().strip_carets().to_string()) .collect::<Vec<String>>(),
root_prefix => &root_url_prefix,
crumbs => crumbs,
};
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&lists_path)
.map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
file.write_all(
TEMPLATES
.get_template("post.html")?
.render(context)?
.as_bytes(),
)?;
lists_path.pop();
}
lists_path.pop();
lists_path.pop();
}
Ok(())
}
fn main() -> std::result::Result<(), i64> {
if let Err(err) = run_app() {
eprintln!("{err}");
return Err(-1);
}
Ok(())
}

View File

@ -1,21 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod cal;
pub mod utils;

View File

@ -1,257 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{fs::OpenOptions, io::Write};
use mailpot::*;
use mailpot_archives::utils::*;
use minijinja::value::Value;
fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().collect::<Vec<_>>();
let Some(config_path) = args.get(1) else {
return Err("Expected configuration file path as first argument.".into());
};
let Some(output_path) = args.get(2) else {
return Err("Expected output dir path as second argument.".into());
};
let root_url_prefix = args.get(3).cloned().unwrap_or_default();
let output_path = std::path::Path::new(&output_path);
if output_path.exists() && !output_path.is_dir() {
return Err("Output path is not a directory.".into());
}
std::fs::create_dir_all(output_path.join("lists"))?;
std::fs::create_dir_all(output_path.join("list"))?;
let conf = Configuration::from_file(config_path)
.map_err(|err| format!("Could not load config {config_path}: {err}"))?;
let db = Connection::open_db(conf).map_err(|err| format!("Couldn't open db: {err}"))?;
let lists_values = db.lists()?;
{
//index.html
let lists = lists_values
.iter()
.map(|list| {
let months = db.months(list.pk).unwrap();
let posts = db.list_posts(list.pk, None).unwrap();
minijinja::context! {
title => &list.name,
posts => &posts,
months => &months,
body => &list.description.as_deref().unwrap_or_default(),
root_prefix => &root_url_prefix,
list => Value::from_object(MailingList::from(list.clone())),
}
})
.collect::<Vec<_>>();
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(output_path.join("index.html"))?;
let crumbs = vec![Crumb {
label: "Lists".into(),
url: format!("{root_url_prefix}/").into(),
}];
let context = minijinja::context! {
title => "mailing list archive",
description => "",
lists => &lists,
root_prefix => &root_url_prefix,
crumbs => crumbs,
};
file.write_all(
TEMPLATES
.get_template("lists.html")?
.render(context)?
.as_bytes(),
)?;
}
let mut lists_path = output_path.to_path_buf();
for list in &lists_values {
lists_path.push("lists");
lists_path.push(list.pk.to_string());
std::fs::create_dir_all(&lists_path)?;
lists_path.push("index.html");
let list = db.list(list.pk)?.unwrap();
let post_policy = db.list_post_policy(list.pk)?;
let months = db.months(list.pk)?;
let posts = db.list_posts(list.pk, None)?;
let mut hist = months
.iter()
.map(|m| (m.to_string(), [0usize; 31]))
.collect::<std::collections::HashMap<String, [usize; 31]>>();
let posts_ctx = posts
.iter()
.map(|post| {
//2019-07-14T14:21:02
if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
}
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.expect("Could not parse mail");
let mut msg_id = &post.message_id[1..];
msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
let subject = envelope.subject();
let mut subject_ref = subject.trim();
if subject_ref.starts_with('[')
&& subject_ref[1..].starts_with(&list.id)
&& subject_ref[1 + list.id.len()..].starts_with(']')
{
subject_ref = subject_ref[2 + list.id.len()..].trim();
}
minijinja::context! {
pk => post.pk,
list => post.list,
subject => subject_ref,
address=> post.address,
message_id => msg_id,
message => post.message,
timestamp => post.timestamp,
datetime => post.datetime,
root_prefix => &root_url_prefix,
}
})
.collect::<Vec<_>>();
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: format!("{root_url_prefix}/").into(),
},
Crumb {
label: list.name.clone().into(),
url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
},
];
let context = minijinja::context! {
title=> &list.name,
description=> &list.description,
post_policy=> &post_policy,
preamble => true,
months=> &months,
hists => &hist,
posts=> posts_ctx,
body=>&list.description.clone().unwrap_or_default(),
root_prefix => &root_url_prefix,
list => Value::from_object(MailingList::from(list.clone())),
crumbs => crumbs,
};
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&lists_path)
.map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
file.write_all(
TEMPLATES
.get_template("list.html")?
.render(context)?
.as_bytes(),
)?;
lists_path.pop();
lists_path.pop();
lists_path.pop();
lists_path.push("list");
lists_path.push(list.pk.to_string());
std::fs::create_dir_all(&lists_path)?;
for post in posts {
let mut msg_id = &post.message_id[1..];
msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
lists_path.push(format!("{msg_id}.html"));
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.map_err(|err| format!("Could not parse mail {}: {err}", post.message_id))?;
let body = envelope.body_bytes(post.message.as_slice());
let body_text = body.text();
let subject = envelope.subject();
let mut subject_ref = subject.trim();
if subject_ref.starts_with('[')
&& subject_ref[1..].starts_with(&list.id)
&& subject_ref[1 + list.id.len()..].starts_with(']')
{
subject_ref = subject_ref[2 + list.id.len()..].trim();
}
let mut message_id = &post.message_id[1..];
message_id = &message_id[..message_id.len().saturating_sub(1)];
let crumbs = vec![
Crumb {
label: "Lists".into(),
url: format!("{root_url_prefix}/").into(),
},
Crumb {
label: list.name.clone().into(),
url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
},
Crumb {
label: subject_ref.to_string().into(),
url: format!("{root_url_prefix}/lists/{}/{message_id}.html/", list.pk).into(),
},
];
let context = minijinja::context! {
title => &list.name,
list => &list,
post => &post,
posts => &posts_ctx,
body => &body_text,
from => &envelope.field_from_to_string(),
date => &envelope.date_as_str(),
to => &envelope.field_to_to_string(),
subject => &envelope.subject(),
trimmed_subject => subject_ref,
in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
references => &envelope .references() .into_iter() .map(|m| m.to_string().as_str().strip_carets().to_string()) .collect::<Vec<String>>(),
root_prefix => &root_url_prefix,
crumbs => crumbs,
};
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&lists_path)
.map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
file.write_all(
TEMPLATES
.get_template("post.html")?
.render(context)?
.as_bytes(),
)?;
lists_path.pop();
}
lists_path.pop();
lists_path.pop();
}
Ok(())
}
fn main() -> std::result::Result<(), i64> {
if let Err(err) = run_app() {
eprintln!("{err}");
return Err(-1);
}
Ok(())
}

View File

@ -1,43 +0,0 @@
{% macro cal(date, hists, root_prefix, pk) %}
{% set c=calendarize(date, hists) %}
{% if c.sum > 0 %}
<table>
<caption align="top">
<!--<a href="{{ root_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
<a href="#" style="color: GrayText;">
{{ c.month_name }} {{ c.year }}
</a>
</caption>
<thead>
<tr>
<th>M</th>
<th>Tu</th>
<th>W</th>
<th>Th</th>
<th>F</th>
<th>Sa</th>
<th>Su</th>
</tr>
</thead>
<tbody>
{% for week in c.weeks %}
<tr>
{% for day in week %}
{% if day == 0 %}
<td></td>
{% else %}
{% set num = c.hist[day-1] %}
{% if num > 0 %}
<td><ruby>{{ day }}<rt>({{ num }})</rt></ruby></td>
{% else %}
<td class="empty">{{ day }}</td>
{% endif %}
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endmacro %}
{% set alias = cal %}

View File

@ -1,8 +0,0 @@
<footer>
<hr />
<p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
</footer>
</main>
</body>
</html>

View File

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
{% include "css.html" %}
</head>
<body>
<main class="layout">
<div class="header">
<h1>{{ title }}</h1>
{% if description %}
<p class="description">{{ description }}</p>
{% endif %}
{% include "menu.html" %}
<hr />
</div>

View File

@ -1,12 +0,0 @@
{% include "header.html" %}
<div class="entry">
<h1>{{title}}</h1>
<div class="body">
<ul>
{% for l in lists %}
<li><a href="{{ root_prefix|safe }}/lists/{{ l.list.pk }}/">{{l.title}}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% include "footer.html" %}

View File

@ -1,82 +0,0 @@
{% include "header.html" %}
<div class="body">
{% if preamble %}
<div id="preamble" class="preamble">
{% if preamble.custom %}
{{ preamble.custom|safe }}
{% else %}
{% if not post_policy.no_subscriptions %}
<h2 id="subscribe">Subscribe</h2>
{% set subscription_mailto=list.subscription_mailto() %}
{% if subscription_mailto %}
{% if subscription_mailto.subject %}
<p>
<a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
</p>
{% endif %}
{% else %}
<p>List is not open for subscriptions.</p>
{% endif %}
{% set unsubscription_mailto=list.unsubscription_mailto() %}
{% if unsubscription_mailto %}
<h2 id="unsubscribe">Unsubscribe</h2>
{% if unsubscription_mailto.subject %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
</p>
{% endif %}
{% endif %}
{% endif %}
<h2 id="post">Post</h2>
{% if post_policy.announce_only %}
<p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
{% elif post_policy.subscription_only %}
<p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
<p>If you are subscribed, you can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% elif post_policy.approval_needed or post_policy.no_subscriptions %}
<p>List is open to all posts <em>after approval</em> by the list owners.</p>
<p>You can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% else %}
<p>List is not open for submissions.</p>
{% endif %}
{% endif %}
</div>
<hr />
{% endif %}
<div class="list">
<h2 id="calendar">Calendar</h2>
<div class="calendar">
{%- from "calendar.html" import cal %}
{% for date in months %}
{{ cal(date, hists, root_prefix, list.pk) }}
{% endfor %}
</div>
<hr />
<h2 id="posts">Posts</h2>
<div class="posts">
<p>{{ posts | length }} post(s)</p>
{% for post in posts %}
<div class="entry">
<span class="subject"><a href="{{ root_prefix|safe }}/list/{{post.list}}/{{ post.message_id }}.html">{{ post.subject }}</a></span>
<span class="metadata">👤&nbsp;<span class="from">{{ post.address }}</span> 📆&nbsp;<span class="date">{{ post.datetime }}</span></span>
<span class="metadata">🪪 &nbsp;<span class="message-id">{{ post.message_id }}</span></span>
</div>
{% endfor %}
</div>
</div>
</div>
{% include "footer.html" %}

View File

@ -1,12 +0,0 @@
{% include "header.html" %}
<div class="body">
<p>{{lists|length}} lists</p>
<div class="entry">
<ul class="lists">
{% for l in lists %}
<li><a href="{{ root_prefix|safe }}/lists/{{ l.list.pk }}/">{{l.title}}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% include "footer.html" %}

View File

@ -1,11 +0,0 @@
<nav aria-label="Breadcrumb" class="breadcrumb">
<ul>
{% for crumb in crumbs %}
{% if loop.last %}
<li><span aria-current="page">{{ crumb.label }}</span></li>
{% else %}
<li><a href="{{ crumb.url }}">{{ crumb.label }}</a></li>
{% endif %}
{% endfor %}
</ul>
</nav>

View File

@ -1,42 +0,0 @@
{% include "header.html" %}
<div class="body">
<h2>{{trimmed_subject}}</h2>
<table class="headers">
<tr>
<th scope="row">List</th>
<td class="faded">{{ list.id }}</td>
</tr>
<tr>
<th scope="row">From</th>
<td>{{ from }}</td>
</tr>
<tr>
<th scope="row">To</th>
<td class="faded">{{ to }}</td>
</tr>
<tr>
<th scope="row">Subject</th>
<td>{{ subject }}</td>
</tr>
<tr>
<th scope="row">Date</th>
<td class="faded">{{ date }}</td>
</tr>
{% if in_reply_to %}
<tr>
<th scope="row">In-Reply-To</th>
<td class="faded message-id"><a href="{{ root_prefix|safe }}/list/{{list.pk}}/{{ in_reply_to }}.html">{{ in_reply_to }}</a></td>
</tr>
{% endif %}
{% if references %}
<tr>
<th scope="row">References</th>
<td>{% for r in references %}<span class="faded message-id"><a href="{{ root_prefix|safe }}/list/{{list.pk}}/{{ r }}.html">{{ r }}</a></span>{% endfor %}</td>
</tr>
{% endif %}
</table>
<div class="post-body">
<pre>{{body}}</pre>
</div>
</div>
{% include "footer.html" %}

View File

@ -1,207 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::borrow::Cow;
use chrono::{Datelike, Month};
use mailpot::{models::DbVal, *};
use minijinja::{
value::{Object, Value},
Environment, Error, Source, State,
};
lazy_static::lazy_static! {
pub static ref TEMPLATES: Environment<'static> = {
let mut env = Environment::new();
env.add_function("calendarize", calendarize);
env.set_source(Source::from_path("src/templates/"));
env
};
}
pub trait StripCarets {
fn strip_carets(&self) -> &str;
}
impl StripCarets for &str {
fn strip_carets(&self) -> &str {
let mut self_ref = self.trim();
if self_ref.starts_with('<') && self_ref.ends_with('>') {
self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
}
self_ref
}
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
pub struct MailingList {
pub pk: i64,
pub name: String,
pub id: String,
pub address: String,
pub description: Option<String>,
pub topics: Vec<String>,
pub archive_url: Option<String>,
pub inner: DbVal<mailpot::models::MailingList>,
}
impl From<DbVal<mailpot::models::MailingList>> for MailingList {
fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
let DbVal(
mailpot::models::MailingList {
pk,
name,
id,
address,
description,
topics,
archive_url,
},
_,
) = val.clone();
Self {
pk,
name,
id,
address,
description,
topics,
archive_url,
inner: val,
}
}
}
impl std::fmt::Display for MailingList {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
self.id.fmt(fmt)
}
}
impl Object for MailingList {
fn kind(&self) -> minijinja::value::ObjectKind {
minijinja::value::ObjectKind::Struct(self)
}
fn call_method(
&self,
_state: &State,
name: &str,
_args: &[Value],
) -> std::result::Result<Value, Error> {
match name {
"subscription_mailto" => {
Ok(Value::from_serializable(&self.inner.subscription_mailto()))
}
"unsubscription_mailto" => Ok(Value::from_serializable(
&self.inner.unsubscription_mailto(),
)),
_ => Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("aaaobject has no method named {name}"),
)),
}
}
}
impl minijinja::value::StructObject for MailingList {
fn get_field(&self, name: &str) -> Option<Value> {
match name {
"pk" => Some(Value::from_serializable(&self.pk)),
"name" => Some(Value::from_serializable(&self.name)),
"id" => Some(Value::from_serializable(&self.id)),
"address" => Some(Value::from_serializable(&self.address)),
"description" => Some(Value::from_serializable(&self.description)),
"topics" => Some(Value::from_serializable(&self.topics)),
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
_ => None,
}
}
fn static_fields(&self) -> Option<&'static [&'static str]> {
Some(
&[
"pk",
"name",
"id",
"address",
"description",
"topics",
"archive_url",
][..],
)
}
}
pub fn calendarize(_state: &State, args: Value, hists: Value) -> std::result::Result<Value, Error> {
macro_rules! month {
($int:expr) => {{
let int = $int;
match int {
1 => Month::January.name(),
2 => Month::February.name(),
3 => Month::March.name(),
4 => Month::April.name(),
5 => Month::May.name(),
6 => Month::June.name(),
7 => Month::July.name(),
8 => Month::August.name(),
9 => Month::September.name(),
10 => Month::October.name(),
11 => Month::November.name(),
12 => Month::December.name(),
_ => unreachable!(),
}
}};
}
let month = args.as_str().unwrap();
let hist = hists
.get_item(&Value::from(month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.collect::<Vec<usize>>();
let sum: usize = hists
.get_item(&Value::from(month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.sum();
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
Ok(minijinja::context! {
month_name => month!(date.month()),
month => month,
month_int => date.month() as usize,
year => date.year(),
weeks => crate::cal::calendarize_with_offset(date, 1),
hist => hist,
sum => sum,
})
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
pub struct Crumb {
pub label: Cow<'static, str>,
pub url: Cow<'static, str>,
}

View File

@ -1,39 +0,0 @@
[package]
name = "mailpot-cli"
version = "0.1.1"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2021"
license = "LICENSE"
readme = "README.md"
description = "mailing list manager"
repository = "https://github.com/meli/mailpot"
keywords = ["mail", "mailing-lists"]
categories = ["email"]
default-run = "mpot"
[[bin]]
name = "mpot"
path = "src/main.rs"
doc-scrape-examples = true
[dependencies]
base64 = { version = "0.21" }
clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] }
log = "0.4"
mailpot = { version = "^0.1", path = "../core" }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
stderrlog = { version = "^0.6" }
ureq = { version = "2.6", default-features = false }
[dev-dependencies]
assert_cmd = "2"
mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
predicates = "3"
tempfile = { version = "3.9" }
[build-dependencies]
clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "wrap_help", "help", "usage", "error-context", "suggestions"] }
clap_mangen = "0.2.10"
mailpot = { version = "^0.1", path = "../core" }
stderrlog = { version = "^0.6" }

View File

@ -1,5 +0,0 @@
# mailpot-cli
```shell
cargo run --bin mpot -- help
```

View File

@ -1,524 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{
collections::{hash_map::RandomState, HashSet, VecDeque},
hash::{BuildHasher, Hasher},
io::Write,
};
use clap::{ArgAction, CommandFactory};
use clap_mangen::{roff, Man};
use roff::{bold, italic, roman, Inline, Roff};
include!("src/args.rs");
fn main() -> std::io::Result<()> {
println!("cargo:rerun-if-changed=./src/lib.rs");
println!("cargo:rerun-if-changed=./build.rs");
std::env::set_current_dir("..").expect("could not chdir('..')");
let out_dir = PathBuf::from("./docs/");
let cmd = Opt::command();
let man = Man::new(cmd.clone()).title("mpot");
let mut buffer: Vec<u8> = Default::default();
man.render_title(&mut buffer)?;
man.render_name_section(&mut buffer)?;
man.render_synopsis_section(&mut buffer)?;
man.render_description_section(&mut buffer)?;
let mut roff = Roff::default();
options(&mut roff, &cmd);
roff.to_writer(&mut buffer)?;
render_quick_start_section(&mut buffer)?;
render_subcommands_section(&mut buffer)?;
let mut visited = HashSet::new();
let mut stack = VecDeque::new();
let mut order = VecDeque::new();
stack.push_back(vec![&cmd]);
let s = RandomState::new();
'stack: while let Some(cmds) = stack.pop_front() {
for sub in cmds.last().unwrap().get_subcommands() {
let mut hasher = s.build_hasher();
for c in cmds.iter() {
hasher.write(c.get_name().as_bytes());
}
hasher.write(sub.get_name().as_bytes());
if visited.insert(hasher.finish()) {
let mut sub_cmds = cmds.clone();
sub_cmds.push(sub);
order.push_back(sub_cmds.clone());
stack.push_front(cmds);
stack.push_front(sub_cmds);
continue 'stack;
}
}
}
while let Some(mut subs) = order.pop_front() {
let sub = subs.pop().unwrap();
render_subcommand(&subs, sub, &mut buffer)?;
}
man.render_authors_section(&mut buffer)?;
std::fs::write(out_dir.join("mpot.1"), buffer)?;
Ok(())
}
fn render_quick_start_section(w: &mut dyn Write) -> Result<(), std::io::Error> {
let mut roff = Roff::default();
let heading = "QUICK START";
roff.control("SH", [heading]);
let tutorial = r#"mailpot saves its data in a sqlite3 file. To define the location of the sqlite3 file we need a configuration file, which can be generated with:
mpot sample-config > conf.toml
Mailing lists can now be created:
mpot -c conf.toml create-list --name "my first list" --id mylist --address mylist@example.com
You can list all the mailing lists with:
mpot -c conf.toml list-lists
You should add yourself as the list owner:
mpot -c conf.toml list mylist add-list-owner --address myself@example.com --name "Nemo"
And also enable posting and subscriptions by setting list policies:
mpot -c conf.toml list mylist add-policy --subscriber-only
mpot -c conf.toml list mylist add-subscribe-policy --request --send-confirmation
To post on a mailing list or submit a list request, pipe a raw e-mail into STDIN:
mpot -c conf.toml post
You can configure your mail server to redirect e-mails addressed to your mailing lists to this command.
For postfix, you can automatically generate this configuration with:
mpot -c conf.toml print-postfix-config --user myself --binary-path /path/to/mpot
This will print the following:
- content of `transport_maps` and `local_recipient_maps`
The output must be saved in a plain text file.
Map output should be added to transport_maps and local_recipient_maps parameters in postfix's main.cf.
To make postfix be able to read them, the postmap application must be executed with the
path to the map file as its sole argument.
postmap /path/to/mylist_maps
postmap is usually distributed along with the other postfix binaries.
- `master.cf` service entry
The output must be entered in the master.cf file.
See <https://www.postfix.org/master.5.html>.
"#;
for line in tutorial.lines() {
roff.text([roman(line.trim())]);
}
roff.to_writer(w)
}
fn render_subcommands_section(w: &mut dyn Write) -> Result<(), std::io::Error> {
let mut roff = Roff::default();
let heading = "SUBCOMMANDS";
roff.control("SH", [heading]);
roff.to_writer(w)
}
fn render_subcommand(
parents: &[&clap::Command],
sub: &clap::Command,
w: &mut dyn Write,
) -> Result<(), std::io::Error> {
let mut roff = Roff::default();
_render_subcommand_full(parents, sub, &mut roff);
options(&mut roff, sub);
roff.to_writer(w)
}
fn _render_subcommand_full(parents: &[&clap::Command], sub: &clap::Command, roff: &mut Roff) {
roff.control("\\fB", []);
roff.control(
"SS",
parents
.iter()
.map(|cmd| cmd.get_name())
.chain(std::iter::once(sub.get_name()))
.collect::<Vec<_>>(),
);
roff.control("\\fR", []);
roff.text([Inline::LineBreak]);
synopsis(roff, parents, sub);
roff.text([Inline::LineBreak]);
if let Some(about) = sub.get_about().or_else(|| sub.get_long_about()) {
let about = about.to_string();
let mut iter = about.lines();
let last = iter.nth_back(0);
for line in iter {
roff.text([roman(line.trim())]);
}
if let Some(line) = last {
roff.text([roman(format!("{}.", line.trim()))]);
}
}
}
fn synopsis(roff: &mut Roff, parents: &[&clap::Command], sub: &clap::Command) {
let mut line = parents
.iter()
.flat_map(|cmd| vec![roman(cmd.get_name()), roman(" ")].into_iter())
.chain(std::iter::once(roman(sub.get_name())))
.chain(std::iter::once(roman(" ")))
.collect::<Vec<_>>();
let arguments = sub
.get_arguments()
.filter(|i| !i.is_hide_set())
.collect::<Vec<_>>();
if arguments.is_empty() && sub.get_positionals().count() == 0 {
return;
}
roff.text([Inline::LineBreak]);
for opt in arguments {
match (opt.get_short(), opt.get_long()) {
(Some(short), Some(long)) => {
let (lhs, rhs) = option_markers(opt);
line.push(roman(lhs));
line.push(roman(format!("-{short}")));
if let Some(value) = opt.get_value_names() {
line.push(roman(" "));
line.push(italic(value.join(" ")));
}
line.push(roman("|"));
line.push(roman(format!("--{long}",)));
line.push(roman(rhs));
}
(Some(short), None) => {
let (lhs, rhs) = option_markers_single(opt);
line.push(roman(lhs));
line.push(roman(format!("-{short}")));
if let Some(value) = opt.get_value_names() {
line.push(roman(" "));
line.push(italic(value.join(" ")));
}
line.push(roman(rhs));
}
(None, Some(long)) => {
let (lhs, rhs) = option_markers_single(opt);
line.push(roman(lhs));
line.push(roman(format!("--{long}")));
if let Some(value) = opt.get_value_names() {
line.push(roman(" "));
line.push(italic(value.join(" ")));
}
line.push(roman(rhs));
}
(None, None) => continue,
};
if matches!(opt.get_action(), ArgAction::Count) {
line.push(roman("..."))
}
line.push(roman(" "));
}
for arg in sub.get_positionals() {
let (lhs, rhs) = option_markers_single(arg);
line.push(roman(lhs));
if let Some(value) = arg.get_value_names() {
line.push(italic(value.join(" ")));
} else {
line.push(italic(arg.get_id().as_str()));
}
line.push(roman(rhs));
line.push(roman(" "));
}
roff.text(line);
}
fn options(roff: &mut Roff, cmd: &clap::Command) {
let items: Vec<_> = cmd.get_arguments().filter(|i| !i.is_hide_set()).collect();
for pos in items.iter().filter(|a| a.is_positional()) {
let mut header = vec![];
let (lhs, rhs) = option_markers_single(pos);
header.push(roman(lhs));
if let Some(value) = pos.get_value_names() {
header.push(italic(value.join(" ")));
} else {
header.push(italic(pos.get_id().as_str()));
};
header.push(roman(rhs));
if let Some(defs) = option_default_values(pos) {
header.push(roman(format!(" {defs}")));
}
let mut body = vec![];
let mut arg_help_written = false;
if let Some(help) = option_help(pos) {
arg_help_written = true;
let mut help = help.to_string();
if !help.ends_with('.') {
help.push('.');
}
body.push(roman(help));
}
roff.control("TP", []);
roff.text(header);
roff.text(body);
if let Some(env) = option_environment(pos) {
roff.control("RS", []);
roff.text(env);
roff.control("RE", []);
}
// If possible options are available
if let Some((possible_values_text, with_help)) = get_possible_values(pos) {
if arg_help_written {
// It looks nice to have a separation between the help and the values
roff.text([Inline::LineBreak]);
}
if with_help {
roff.text([Inline::LineBreak, italic("Possible values:")]);
// Need to indent twice to get it to look right, because .TP heading indents,
// but that indent doesn't Carry over to the .IP for the
// bullets. The standard shift size is 7 for terminal devices
roff.control("RS", ["14"]);
for line in possible_values_text {
roff.control("IP", ["\\(bu", "2"]);
roff.text([roman(line)]);
}
roff.control("RE", []);
} else {
let possible_value_text: Vec<Inline> = vec![
Inline::LineBreak,
roman("["),
italic("possible values: "),
roman(possible_values_text.join(", ")),
roman("]"),
];
roff.text(possible_value_text);
}
}
}
for opt in items.iter().filter(|a| !a.is_positional()) {
let mut header = match (opt.get_short(), opt.get_long()) {
(Some(short), Some(long)) => {
vec![short_option(short), roman(", "), long_option(long)]
}
(Some(short), None) => vec![short_option(short)],
(None, Some(long)) => vec![long_option(long)],
(None, None) => vec![],
};
if opt.get_action().takes_values() {
if let Some(value) = &opt.get_value_names() {
header.push(roman(" "));
header.push(italic(value.join(" ")));
}
}
if let Some(defs) = option_default_values(opt) {
header.push(roman(" "));
header.push(roman(defs));
}
let mut body = vec![];
let mut arg_help_written = false;
if let Some(help) = option_help(opt) {
arg_help_written = true;
let mut help = help.to_string();
if !help.as_str().ends_with('.') {
help.push('.');
}
body.push(roman(help));
}
roff.control("TP", []);
roff.text(header);
roff.text(body);
if let Some((possible_values_text, with_help)) = get_possible_values(opt) {
if arg_help_written {
// It looks nice to have a separation between the help and the values
roff.text([Inline::LineBreak, Inline::LineBreak]);
}
if with_help {
roff.text([Inline::LineBreak, italic("Possible values:")]);
// Need to indent twice to get it to look right, because .TP heading indents,
// but that indent doesn't Carry over to the .IP for the
// bullets. The standard shift size is 7 for terminal devices
roff.control("RS", ["14"]);
for line in possible_values_text {
roff.control("IP", ["\\(bu", "2"]);
roff.text([roman(line)]);
}
roff.control("RE", []);
} else {
let possible_value_text: Vec<Inline> = vec![
Inline::LineBreak,
roman("["),
italic("possible values: "),
roman(possible_values_text.join(", ")),
roman("]"),
];
roff.text(possible_value_text);
}
}
if let Some(env) = option_environment(opt) {
roff.control("RS", []);
roff.text(env);
roff.control("RE", []);
}
}
}
fn option_markers(opt: &clap::Arg) -> (&'static str, &'static str) {
markers(opt.is_required_set())
}
fn option_markers_single(opt: &clap::Arg) -> (&'static str, &'static str) {
if opt.is_required_set() {
("", "")
} else {
markers(opt.is_required_set())
}
}
fn markers(required: bool) -> (&'static str, &'static str) {
if required {
("{", "}")
} else {
("[", "]")
}
}
fn short_option(opt: char) -> Inline {
roman(format!("-{opt}"))
}
fn long_option(opt: &str) -> Inline {
roman(format!("--{opt}"))
}
fn option_help(opt: &clap::Arg) -> Option<&clap::builder::StyledStr> {
if !opt.is_hide_long_help_set() {
let long_help = opt.get_long_help();
if long_help.is_some() {
return long_help;
}
}
if !opt.is_hide_short_help_set() {
return opt.get_help();
}
None
}
fn option_environment(opt: &clap::Arg) -> Option<Vec<Inline>> {
if opt.is_hide_env_set() {
return None;
} else if let Some(env) = opt.get_env() {
return Some(vec![
roman("May also be specified with the "),
bold(env.to_string_lossy().into_owned()),
roman(" environment variable. "),
]);
}
None
}
fn option_default_values(opt: &clap::Arg) -> Option<String> {
if opt.is_hide_default_value_set() || !opt.get_action().takes_values() {
return None;
} else if !opt.get_default_values().is_empty() {
let values = opt
.get_default_values()
.iter()
.map(|s| s.to_string_lossy())
.collect::<Vec<_>>()
.join(",");
return Some(format!("[default: {values}]"));
}
None
}
fn get_possible_values(arg: &clap::Arg) -> Option<(Vec<String>, bool)> {
let possibles = &arg.get_possible_values();
let possibles: Vec<&clap::builder::PossibleValue> =
possibles.iter().filter(|pos| !pos.is_hide_set()).collect();
if !(possibles.is_empty() || arg.is_hide_possible_values_set()) {
return Some(format_possible_values(&possibles));
}
None
}
fn format_possible_values(possibles: &Vec<&clap::builder::PossibleValue>) -> (Vec<String>, bool) {
let mut lines = vec![];
let with_help = possibles.iter().any(|p| p.get_help().is_some());
if with_help {
for value in possibles {
let val_name = value.get_name();
match value.get_help() {
Some(help) => lines.push(format!(
"{val_name}: {help}{period}",
period = if help.to_string().ends_with('.') {
""
} else {
"."
}
)),
None => lines.push(val_name.to_string()),
}
}
} else {
lines.append(&mut possibles.iter().map(|p| p.get_name().to_string()).collect());
}
(lines, with_help)
}

View File

@ -1 +0,0 @@
../rustfmt.toml

View File

@ -1,571 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub use std::path::PathBuf;
pub use clap::{builder::TypedValueParser, Args, Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "mpot",
about = "mailing list manager",
long_about = "Tool for mailpot mailing list management.",
before_long_help = "GNU Affero version 3 or later <https://www.gnu.org/licenses/>",
author,
version
)]
pub struct Opt {
/// Print logs.
#[arg(short, long)]
pub debug: bool,
/// Configuration file to use.
#[arg(short, long, value_parser)]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub cmd: Command,
/// Silence all output.
#[arg(short, long)]
pub quiet: bool,
/// Verbose mode (-v, -vv, -vvv, etc).
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Debug log timestamp (sec, ms, ns, none).
#[arg(short, long)]
pub ts: Option<stderrlog::Timestamp>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Prints a sample config file to STDOUT.
///
/// You can generate a new configuration file by writing the output to a
/// file, e.g: mpot sample-config --with-smtp > config.toml
SampleConfig {
/// Use an SMTP connection instead of a shell process.
#[arg(long)]
with_smtp: bool,
},
/// Dumps database data to STDOUT.
DumpDatabase,
/// Lists all registered mailing lists.
ListLists,
/// Mailing list management.
List {
/// Selects mailing list to operate on.
list_id: String,
#[command(subcommand)]
cmd: ListCommand,
},
/// Create new list.
CreateList {
/// List name.
#[arg(long)]
name: String,
/// List ID.
#[arg(long)]
id: String,
/// List e-mail address.
#[arg(long)]
address: String,
/// List description.
#[arg(long)]
description: Option<String>,
/// List archive URL.
#[arg(long)]
archive_url: Option<String>,
},
/// Post message from STDIN to list.
Post {
/// Show e-mail processing result without actually consuming it.
#[arg(long)]
dry_run: bool,
},
/// Flush outgoing e-mail queue.
FlushQueue {
/// Show e-mail processing result without actually consuming it.
#[arg(long)]
dry_run: bool,
},
/// Processed mail is stored in queues.
Queue {
#[arg(long, value_parser = QueueValueParser)]
queue: mailpot::queue::Queue,
#[command(subcommand)]
cmd: QueueCommand,
},
/// Import a maildir folder into an existing list.
ImportMaildir {
/// List-ID or primary key value.
list_id: String,
/// Path to a maildir mailbox.
/// Must contain {cur, tmp, new} folders.
#[arg(long, value_parser)]
maildir_path: PathBuf,
},
/// Update postfix maps and master.cf (probably needs root permissions).
UpdatePostfixConfig {
#[arg(short = 'p', long)]
/// Override location of master.cf file (default:
/// /etc/postfix/master.cf)
master_cf: Option<PathBuf>,
#[clap(flatten)]
config: PostfixConfig,
},
/// Print postfix maps and master.cf entry to STDOUT.
///
/// Map output should be added to transport_maps and local_recipient_maps
/// parameters in postfix's main.cf. It must be saved in a plain text
/// file. To make postfix be able to read them, the postmap application
/// must be executed with the path to the map file as its sole argument.
///
/// postmap /path/to/mylist_maps
///
/// postmap is usually distributed along with the other postfix binaries.
///
/// The master.cf entry must be manually appended to the master.cf file. See <https://www.postfix.org/master.5.html>.
PrintPostfixConfig {
#[clap(flatten)]
config: PostfixConfig,
},
/// All Accounts.
Accounts,
/// Account info.
AccountInfo {
/// Account address.
address: String,
},
/// Add account.
AddAccount {
/// E-mail address.
#[arg(long)]
address: String,
/// SSH public key for authentication.
#[arg(long)]
password: String,
/// Name.
#[arg(long)]
name: Option<String>,
/// Public key.
#[arg(long)]
public_key: Option<String>,
#[arg(long)]
/// Is account enabled.
enabled: Option<bool>,
},
/// Remove account.
RemoveAccount {
#[arg(long)]
/// E-mail address.
address: String,
},
/// Update account info.
UpdateAccount {
/// Address to edit.
address: String,
/// Public key for authentication.
#[arg(long)]
password: Option<String>,
/// Name.
#[arg(long)]
name: Option<Option<String>>,
/// Public key.
#[arg(long)]
public_key: Option<Option<String>>,
#[arg(long)]
/// Is account enabled.
enabled: Option<Option<bool>>,
},
/// Show and fix possible data mistakes or inconsistencies.
Repair {
/// Fix errors (default: false)
#[arg(long, default_value = "false")]
fix: bool,
/// Select all tests (default: false)
#[arg(long, default_value = "false")]
all: bool,
/// Post `datetime` column must have the Date: header value, in RFC2822
/// format.
#[arg(long, default_value = "false")]
datetime_header_value: bool,
/// Remove accounts that have no matching subscriptions.
#[arg(long, default_value = "false")]
remove_empty_accounts: bool,
/// Remove subscription requests that have been accepted.
#[arg(long, default_value = "false")]
remove_accepted_subscription_requests: bool,
/// Warn if a list has no owners.
#[arg(long, default_value = "false")]
warn_list_no_owner: bool,
},
}
/// Postfix config values.
#[derive(Debug, Args)]
pub struct PostfixConfig {
/// User that runs mailpot when postfix relays a message.
///
/// Must not be the `postfix` user.
/// Must have permissions to access the database file and the data
/// directory.
#[arg(short, long)]
pub user: String,
/// Group that runs mailpot when postfix relays a message.
/// Optional.
#[arg(short, long)]
pub group: Option<String>,
/// The path to the mailpot binary postfix will execute.
#[arg(long)]
pub binary_path: PathBuf,
/// Limit the number of mailpot instances that can exist at the same time.
///
/// Default is 1.
#[arg(long, default_value = "1")]
pub process_limit: Option<u64>,
/// The directory in which the map files are saved.
///
/// Default is `data_path` from [`Configuration`](mailpot::Configuration).
#[arg(long)]
pub map_output_path: Option<PathBuf>,
/// The name of the postfix service name to use.
/// Default is `mailpot`.
///
/// A postfix service is a daemon managed by the postfix process.
/// Each entry in the `master.cf` configuration file defines a single
/// service.
///
/// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
/// <https://www.postfix.org/master.5.html>.
#[arg(long)]
pub transport_name: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum QueueCommand {
/// List.
List,
/// Print entry in RFC5322 or JSON format.
Print {
/// index of entry.
#[arg(long)]
index: Vec<i64>,
},
/// Delete entry and print it in stdout.
Delete {
/// index of entry.
#[arg(long)]
index: Vec<i64>,
},
}
/// Subscription options.
#[derive(Debug, Args)]
pub struct SubscriptionOptions {
/// Name.
#[arg(long)]
pub name: Option<String>,
/// Send messages as digest.
#[arg(long, default_value = "false")]
pub digest: Option<bool>,
/// Hide message from list when posting.
#[arg(long, default_value = "false")]
pub hide_address: Option<bool>,
/// Hide message from list when posting.
#[arg(long, default_value = "false")]
/// E-mail address verification status.
pub verified: Option<bool>,
#[arg(long, default_value = "true")]
/// Receive confirmation email when posting.
pub receive_confirmation: Option<bool>,
#[arg(long, default_value = "true")]
/// Receive posts from list even if address exists in To or Cc header.
pub receive_duplicates: Option<bool>,
#[arg(long, default_value = "false")]
/// Receive own posts from list.
pub receive_own_posts: Option<bool>,
#[arg(long, default_value = "true")]
/// Is subscription enabled.
pub enabled: Option<bool>,
}
/// Account options.
#[derive(Debug, Args)]
pub struct AccountOptions {
/// Name.
#[arg(long)]
pub name: Option<String>,
/// Public key.
#[arg(long)]
pub public_key: Option<String>,
#[arg(long)]
/// Is account enabled.
pub enabled: Option<bool>,
}
#[derive(Debug, Subcommand)]
pub enum ListCommand {
/// List subscriptions of list.
Subscriptions,
/// List subscription requests.
SubscriptionRequests,
/// Add subscription to list.
AddSubscription {
/// E-mail address.
#[arg(long)]
address: String,
#[clap(flatten)]
subscription_options: SubscriptionOptions,
},
/// Remove subscription from list.
RemoveSubscription {
#[arg(long)]
/// E-mail address.
address: String,
},
/// Update subscription info.
UpdateSubscription {
/// Address to edit.
address: String,
#[clap(flatten)]
subscription_options: SubscriptionOptions,
},
/// Accept a subscription request by its primary key.
AcceptSubscriptionRequest {
/// The primary key of the request.
pk: i64,
/// Do not send confirmation e-mail.
#[arg(long, default_value = "false")]
do_not_send_confirmation: bool,
},
/// Send subscription confirmation manually.
SendConfirmationForSubscription {
/// The primary key of the subscription.
pk: i64,
},
/// Add a new post policy.
AddPostPolicy {
#[arg(long)]
/// Only list owners can post.
announce_only: bool,
#[arg(long)]
/// Only subscriptions can post.
subscription_only: bool,
#[arg(long)]
/// Subscriptions can post.
/// Other posts must be approved by list owners.
approval_needed: bool,
#[arg(long)]
/// Anyone can post without restrictions.
open: bool,
#[arg(long)]
/// Allow posts, but handle it manually.
custom: bool,
},
// Remove post policy.
RemovePostPolicy {
#[arg(long)]
/// Post policy primary key.
pk: i64,
},
/// Add subscription policy to list.
AddSubscriptionPolicy {
#[arg(long)]
/// Send confirmation e-mail when subscription is finalized.
send_confirmation: bool,
#[arg(long)]
/// Anyone can subscribe without restrictions.
open: bool,
#[arg(long)]
/// Only list owners can manually add subscriptions.
manual: bool,
#[arg(long)]
/// Anyone can request to subscribe.
request: bool,
#[arg(long)]
/// Allow subscriptions, but handle it manually.
custom: bool,
},
RemoveSubscriptionPolicy {
#[arg(long)]
/// Subscription policy primary key.
pk: i64,
},
/// Add list owner to list.
AddListOwner {
#[arg(long)]
address: String,
#[arg(long)]
name: Option<String>,
},
RemoveListOwner {
#[arg(long)]
/// List owner primary key.
pk: i64,
},
/// Alias for update-subscription --enabled true.
EnableSubscription {
/// Subscription address.
address: String,
},
/// Alias for update-subscription --enabled false.
DisableSubscription {
/// Subscription address.
address: String,
},
/// Update mailing list details.
Update {
/// New list name.
#[arg(long)]
name: Option<String>,
/// New List-ID.
#[arg(long)]
id: Option<String>,
/// New list address.
#[arg(long)]
address: Option<String>,
/// New list description.
#[arg(long)]
description: Option<String>,
/// New list archive URL.
#[arg(long)]
archive_url: Option<String>,
/// New owner address local part.
/// If empty, it defaults to '+owner'.
#[arg(long)]
owner_local_part: Option<String>,
/// New request address local part.
/// If empty, it defaults to '+request'.
#[arg(long)]
request_local_part: Option<String>,
/// Require verification of e-mails for new subscriptions.
///
/// Subscriptions that are initiated from the subscription's address are
/// verified automatically.
#[arg(long)]
verify: Option<bool>,
/// Public visibility of list.
///
/// If hidden, the list will not show up in public APIs unless
/// requests to it won't work.
#[arg(long)]
hidden: Option<bool>,
/// Enable or disable the list's functionality.
///
/// If not enabled, the list will continue to show up in the database
/// but e-mails and requests to it won't work.
#[arg(long)]
enabled: Option<bool>,
},
/// Show mailing list health status.
Health,
/// Show mailing list info.
Info,
/// Import members in a local list from a remote mailman3 REST API instance.
///
/// To find the id of the remote list, you can check URL/lists.
/// Example with curl:
///
/// curl --anyauth -u admin:pass "http://localhost:9001/3.0/lists"
///
/// If you're trying to import an entire list, create it first and then
/// import its users with this command.
///
/// Example:
/// mpot -c conf.toml list list-general import-members --url "http://localhost:9001/3.0/" --username admin --password password --list-id list-general.example.com --skip-owners --dry-run
ImportMembers {
#[arg(long)]
/// REST HTTP endpoint e.g. http://localhost:9001/3.0/
url: String,
#[arg(long)]
/// REST HTTP Basic Authentication username.
username: String,
#[arg(long)]
/// REST HTTP Basic Authentication password.
password: String,
#[arg(long)]
/// List ID of remote list to query.
list_id: String,
/// Show what would be inserted without performing any changes.
#[arg(long)]
dry_run: bool,
/// Don't import list owners.
#[arg(long)]
skip_owners: bool,
},
}
#[derive(Clone, Copy, Debug)]
pub struct QueueValueParser;
impl QueueValueParser {
pub fn new() -> Self {
Self
}
}
impl TypedValueParser for QueueValueParser {
type Value = mailpot::queue::Queue;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> std::result::Result<Self::Value, clap::Error> {
TypedValueParser::parse(self, cmd, arg, value.to_owned())
}
fn parse(
&self,
cmd: &clap::Command,
_arg: Option<&clap::Arg>,
value: std::ffi::OsString,
) -> std::result::Result<Self::Value, clap::Error> {
use std::str::FromStr;
use clap::error::ErrorKind;
if value.is_empty() {
return Err(cmd.clone().error(
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
"queue value required",
));
}
Self::Value::from_str(value.to_str().ok_or_else(|| {
cmd.clone().error(
ErrorKind::InvalidValue,
"Queue value is not an UTF-8 string",
)
})?)
.map_err(|err| cmd.clone().error(ErrorKind::InvalidValue, err))
}
fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue>>> {
Some(Box::new(
mailpot::queue::Queue::possible_values()
.iter()
.map(clap::builder::PossibleValue::new),
))
}
}
impl Default for QueueValueParser {
fn default() -> Self {
Self::new()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,149 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2023 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{borrow::Cow, time::Duration};
use base64::{engine::general_purpose, Engine as _};
use mailpot::models::{ListOwner, ListSubscription};
use ureq::Agent;
pub struct Mailman3Connection {
agent: Agent,
url: Cow<'static, str>,
auth: String,
}
impl Mailman3Connection {
pub fn new(
url: &str,
username: &str,
password: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let agent: Agent = ureq::AgentBuilder::new()
.timeout_read(Duration::from_secs(5))
.timeout_write(Duration::from_secs(5))
.build();
let mut buf = String::new();
general_purpose::STANDARD
.encode_string(format!("{username}:{password}").as_bytes(), &mut buf);
let auth: String = format!("Basic {buf}");
Ok(Self {
agent,
url: url.trim_end_matches('/').to_string().into(),
auth,
})
}
pub fn users(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
let response: String = self
.agent
.get(&format!(
"{}/lists/{list_address}/roster/member?fields=email&fields=display_name",
self.url
))
.set("Authorization", &self.auth)
.call()?
.into_string()?;
Ok(serde_json::from_str::<Roster>(&response)?.entries)
}
pub fn owners(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
let response: String = self
.agent
.get(&format!(
"{}/lists/{list_address}/roster/owner?fields=email&fields=display_name",
self.url
))
.set("Authorization", &self.auth)
.call()?
.into_string()?;
Ok(serde_json::from_str::<Roster>(&response)?.entries)
}
}
#[derive(serde::Deserialize, Debug)]
pub struct Roster {
pub entries: Vec<Entry>,
}
#[derive(serde::Deserialize, Debug)]
pub struct Entry {
display_name: String,
email: String,
}
impl Entry {
pub fn display_name(&self) -> Option<&str> {
if !self.display_name.trim().is_empty() && &self.display_name != "None" {
Some(&self.display_name)
} else {
None
}
}
pub fn email(&self) -> &str {
&self.email
}
pub fn into_subscription(self, list: i64) -> ListSubscription {
let Self {
display_name,
email,
} = self;
ListSubscription {
pk: -1,
list,
address: email,
name: if !display_name.trim().is_empty() && &display_name != "None" {
Some(display_name)
} else {
None
},
account: None,
enabled: true,
verified: true,
digest: false,
hide_address: false,
receive_duplicates: false,
receive_own_posts: false,
receive_confirmation: false,
}
}
pub fn into_owner(self, list: i64) -> ListOwner {
let Self {
display_name,
email,
} = self;
ListOwner {
pk: -1,
list,
address: email,
name: if !display_name.trim().is_empty() && &display_name != "None" {
Some(display_name)
} else {
None
},
}
}
}

View File

@ -1,29 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
extern crate base64;
extern crate ureq;
pub use std::path::PathBuf;
mod args;
pub mod commands;
pub mod import;
pub mod lints;
pub use args::*;
pub use clap::{Args, CommandFactory, Parser, Subcommand};

View File

@ -1,262 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{
chrono,
melib::{self, Envelope},
models::{Account, DbVal, ListSubscription, MailingList},
rusqlite, Connection, Result,
};
pub fn datetime_header_value_lint(db: &mut Connection, dry_run: bool) -> Result<()> {
let mut col = vec![];
{
let mut stmt = db.connection.prepare("SELECT * FROM post ORDER BY pk")?;
let iter = stmt.query_map([], |row| {
let pk: i64 = row.get("pk")?;
let date_s: String = row.get("datetime")?;
match melib::utils::datetime::rfc822_to_timestamp(date_s.trim()) {
Err(_) | Ok(0) => {
let mut timestamp: i64 = row.get("timestamp")?;
let created: i64 = row.get("created")?;
if timestamp == 0 {
timestamp = created;
}
timestamp = std::cmp::min(timestamp, created);
let timestamp = if timestamp <= 0 {
None
} else {
// safe because we checked it's not negative or zero above.
Some(timestamp as u64)
};
let message: Vec<u8> = row.get("message")?;
Ok(Some((pk, date_s, message, timestamp)))
}
Ok(_) => Ok(None),
}
})?;
for entry in iter {
if let Some(s) = entry? {
col.push(s);
}
}
}
let mut failures = 0;
let tx = if dry_run {
None
} else {
Some(db.connection.transaction()?)
};
if col.is_empty() {
println!("datetime_header_value: ok");
} else {
println!("datetime_header_value: found {} entries", col.len());
println!("pk\tDate value\tshould be");
for (pk, val, message, timestamp) in col {
let correct = if let Ok(v) =
chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc3339(&val)
{
v.to_rfc2822()
} else if let Some(v) = timestamp.map(|t| {
melib::utils::datetime::timestamp_to_string(
t,
Some(melib::utils::datetime::formats::RFC822_DATE),
true,
)
}) {
v
} else if let Ok(v) =
Envelope::from_bytes(&message, None).map(|env| env.date_as_str().to_string())
{
v
} else {
failures += 1;
println!("{pk}\t{val}\tCould not find any valid date value in the post metadata!");
continue;
};
println!("{pk}\t{val}\t{correct}");
if let Some(tx) = tx.as_ref() {
tx.execute(
"UPDATE post SET datetime = ? WHERE pk = ?",
rusqlite::params![&correct, pk],
)?;
}
}
}
if let Some(tx) = tx {
tx.commit()?;
}
if failures > 0 {
println!(
"datetime_header_value: {failures} failure{}",
if failures == 1 { "" } else { "s" }
);
}
Ok(())
}
pub fn remove_empty_accounts_lint(db: &mut Connection, dry_run: bool) -> Result<()> {
let mut col = vec![];
{
let mut stmt = db.connection.prepare(
"SELECT * FROM account WHERE NOT EXISTS (SELECT 1 FROM subscription AS s WHERE \
s.address = address) ORDER BY pk",
)?;
let iter = stmt.query_map([], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
})?;
for entry in iter {
let entry = entry?;
col.push(entry);
}
}
if col.is_empty() {
println!("remove_empty_accounts: ok");
} else {
let tx = if dry_run {
None
} else {
Some(db.connection.transaction()?)
};
println!("remove_empty_accounts: found {} entries", col.len());
println!("pk\tAddress");
for DbVal(Account { pk, address, .. }, _) in &col {
println!("{pk}\t{address}");
}
if let Some(tx) = tx {
for DbVal(_, pk) in col {
tx.execute("DELETE FROM account WHERE pk = ?", [pk])?;
}
tx.commit()?;
}
}
Ok(())
}
pub fn remove_accepted_subscription_requests_lint(
db: &mut Connection,
dry_run: bool,
) -> Result<()> {
let mut col = vec![];
{
let mut stmt = db.connection.prepare(
"SELECT * FROM candidate_subscription WHERE accepted IS NOT NULL ORDER BY pk",
)?;
let iter = stmt.query_map([], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
for entry in iter {
let entry = entry?;
col.push(entry);
}
}
if col.is_empty() {
println!("remove_accepted_subscription_requests: ok");
} else {
let tx = if dry_run {
None
} else {
Some(db.connection.transaction()?)
};
println!(
"remove_accepted_subscription_requests: found {} entries",
col.len()
);
println!("pk\tAddress");
for DbVal(ListSubscription { pk, address, .. }, _) in &col {
println!("{pk}\t{address}");
}
if let Some(tx) = tx {
for DbVal(_, pk) in col {
tx.execute("DELETE FROM candidate_subscription WHERE pk = ?", [pk])?;
}
tx.commit()?;
}
}
Ok(())
}
pub fn warn_list_no_owner_lint(db: &mut Connection, _: bool) -> Result<()> {
let mut stmt = db.connection.prepare(
"SELECT * FROM list WHERE NOT EXISTS (SELECT 1 FROM owner AS o WHERE o.list = pk) ORDER \
BY pk",
)?;
let iter = stmt.query_map([], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
MailingList {
pk,
name: row.get("name")?,
id: row.get("id")?,
address: row.get("address")?,
description: row.get("description")?,
topics: vec![],
archive_url: row.get("archive_url")?,
},
pk,
))
})?;
let mut col = vec![];
for entry in iter {
let entry = entry?;
col.push(entry);
}
if col.is_empty() {
println!("warn_list_no_owner: ok");
} else {
println!("warn_list_no_owner: found {} entries", col.len());
println!("pk\tName");
for DbVal(MailingList { pk, name, .. }, _) in col {
println!("{pk}\t{name}");
}
}
Ok(())
}

View File

@ -1,221 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{melib::smtp, Configuration, Connection, Context, Result};
use mailpot_cli::{commands::*, *};
fn run_app(
config: Option<PathBuf>,
cmd: Command,
debug: bool,
quiet: bool,
verbose: u8,
) -> Result<()> {
if let Command::SampleConfig { with_smtp } = cmd {
let mut new = Configuration::new("/path/to/sqlite.db");
new.administrators.push("admin@example.com".to_string());
if with_smtp {
new.send_mail = mailpot::SendMail::Smtp(smtp::SmtpServerConf {
hostname: "mail.example.com".to_string(),
port: 587,
envelope_from: "".to_string(),
auth: smtp::SmtpAuth::Auto {
username: "user".to_string(),
password: smtp::Password::Raw("hunter2".to_string()),
auth_type: smtp::SmtpAuthType::default(),
require_auth: true,
},
security: smtp::SmtpSecurity::StartTLS {
danger_accept_invalid_certs: false,
},
extensions: Default::default(),
});
}
println!("{}", new.to_toml());
return Ok(());
};
let config_path = if let Some(path) = config.as_deref() {
path
} else {
let mut opt = Opt::command();
opt.error(
clap::error::ErrorKind::MissingRequiredArgument,
"--config is required for mailing list operations",
)
.exit();
};
let config = Configuration::from_file(config_path).with_context(|| {
format!(
"Could not read configuration file from path: {}",
config_path.display()
)
})?;
use Command::*;
let mut db = Connection::open_or_create_db(config)
.context("Could not open database connection with this configuration")?
.trusted();
match cmd {
SampleConfig { .. } => {}
DumpDatabase => {
dump_database(&mut db).context("Could not dump database.")?;
}
ListLists => {
list_lists(&mut db).context("Could not retrieve mailing lists.")?;
}
List { list_id, cmd } => {
list(&mut db, &list_id, cmd, quiet).map_err(|err| {
err.chain_err(|| {
mailpot::Error::from(format!("Could not perform list command for {list_id}."))
})
})?;
}
CreateList {
name,
id,
address,
description,
archive_url,
} => {
create_list(&mut db, name, id, address, description, archive_url, quiet)
.context("Could not create list.")?;
}
Post { dry_run } => {
post(&mut db, dry_run, debug).context("Could not process post.")?;
}
FlushQueue { dry_run } => {
flush_queue(&mut db, dry_run, verbose, debug).with_context(|| {
format!("Could not flush queue {}.", mailpot::queue::Queue::Out)
})?;
}
Queue { queue, cmd } => {
queue_(&mut db, queue, cmd, quiet)
.with_context(|| format!("Could not perform queue command for queue `{queue}`."))?;
}
ImportMaildir {
list_id,
maildir_path,
} => {
import_maildir(
&mut db,
&list_id,
maildir_path.clone(),
quiet,
debug,
verbose,
)
.with_context(|| {
format!(
"Could not import maildir path {} to list `{list_id}`.",
maildir_path.display(),
)
})?;
}
UpdatePostfixConfig { master_cf, config } => {
update_postfix_config(config_path, &mut db, master_cf, config)
.context("Could not update postfix configuration.")?;
}
PrintPostfixConfig { config } => {
print_postfix_config(config_path, &mut db, config)
.context("Could not print postfix configuration.")?;
}
Accounts => {
accounts(&mut db, quiet).context("Could not retrieve accounts.")?;
}
AccountInfo { address } => {
account_info(&mut db, &address, quiet).with_context(|| {
format!("Could not retrieve account info for address {address}.")
})?;
}
AddAccount {
address,
password,
name,
public_key,
enabled,
} => {
add_account(&mut db, address, password, name, public_key, enabled)
.context("Could not add account.")?;
}
RemoveAccount { address } => {
remove_account(&mut db, &address, quiet)
.with_context(|| format!("Could not remove account with address {address}."))?;
}
UpdateAccount {
address,
password,
name,
public_key,
enabled,
} => {
update_account(&mut db, address, password, name, public_key, enabled)
.context("Could not update account.")?;
}
Repair {
fix,
all,
datetime_header_value,
remove_empty_accounts,
remove_accepted_subscription_requests,
warn_list_no_owner,
} => {
repair(
&mut db,
fix,
all,
datetime_header_value,
remove_empty_accounts,
remove_accepted_subscription_requests,
warn_list_no_owner,
)
.context("Could not perform database repair.")?;
}
}
Ok(())
}
fn main() -> std::result::Result<(), i32> {
let opt = Opt::parse();
stderrlog::new()
.module(module_path!())
.module("mailpot")
.quiet(opt.quiet)
.verbosity(opt.verbose as usize)
.timestamp(opt.ts.unwrap_or(stderrlog::Timestamp::Off))
.init()
.unwrap();
if opt.debug {
println!("DEBUG: {:?}", &opt);
}
let Opt {
config,
cmd,
debug,
quiet,
verbose,
..
} = opt;
if let Err(err) = run_app(config, cmd, debug, quiet, verbose) {
print!("{}", err.display_chain());
std::process::exit(-1);
}
Ok(())
}

View File

@ -1,268 +0,0 @@
/*
* meli - email module
*
* Copyright 2019 Manos Pitsidianakis
*
* This file is part of meli.
*
* meli is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* meli is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with meli. If not, see <http://www.gnu.org/licenses/>.
*/
#![deny(dead_code)]
use std::path::Path;
use assert_cmd::{assert::OutputAssertExt, Command};
use mailpot::{models::*, Configuration, Connection, SendMail};
use predicates::prelude::*;
use tempfile::TempDir;
#[test]
fn test_cli_basic_interfaces() {
fn no_args() {
let mut cmd = Command::cargo_bin("mpot").unwrap();
// 2 -> incorrect usage
cmd.assert().code(2);
}
fn version() {
// --version is successful
for arg in ["--version", "-V"] {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd.arg(arg).output().unwrap().assert();
output.code(0).stdout(predicates::str::starts_with("mpot "));
}
}
fn help() {
// --help is successful
for (arg, starts_with) in [
("--help", "GNU Affero version 3 or later"),
("-h", "mailing list manager"),
] {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd.arg(arg).output().unwrap().assert();
output
.code(0)
.stdout(predicates::str::starts_with(starts_with))
.stdout(predicates::str::contains("Usage:"));
}
}
fn sample_config() {
let mut cmd = Command::cargo_bin("mpot").unwrap();
// sample-config does not require a configuration file as an argument (but other
// commands do)
let output = cmd.arg("sample-config").output().unwrap().assert();
output.code(0).stdout(predicates::str::is_empty().not());
}
fn config_required() {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd.arg("list-lists").output().unwrap().assert();
output.code(2).stdout(predicates::str::is_empty()).stderr(
predicate::eq(
r#"error: --config is required for mailing list operations
Usage: mpot [OPTIONS] <COMMAND>
For more information, try '--help'."#,
)
.trim()
.normalize(),
);
}
no_args();
version();
help();
sample_config();
config_required();
let tmp_dir = TempDir::new().unwrap();
let conf_path = tmp_dir.path().join("conf.toml");
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let config_str = config.to_toml();
fn config_not_exists(conf: &Path) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("list-lists")
.output()
.unwrap()
.assert();
output.code(255).stderr(predicates::str::is_empty()).stdout(
predicate::eq(
format!(
"[1] Could not read configuration file from path: {path} Caused by:\n[2] \
Configuration file {path} not found. Caused by:\n[3] Error returned from \
internal I/O operation: No such file or directory (os error 2)",
path = conf.display()
)
.as_str(),
)
.trim()
.normalize(),
);
}
config_not_exists(&conf_path);
std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
fn list_lists(conf: &Path, eq: &str) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("list-lists")
.output()
.unwrap()
.assert();
output
.code(0)
.stderr(predicates::str::is_empty())
.stdout(predicate::eq(eq).trim().normalize());
}
list_lists(&conf_path, "No lists found.");
{
let db = Connection::open_or_create_db(config).unwrap().trusted();
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
topics: vec![],
description: None,
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
}
list_lists(
&conf_path,
"- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
\"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
owners: None\n\tPost policy: None\n\tSubscription policy: None",
);
fn create_list(conf: &Path) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("create-list")
.arg("--name")
.arg("twobar")
.arg("--id")
.arg("twobar-chat")
.arg("--address")
.arg("twobar-chat@example.com")
.output()
.unwrap()
.assert();
output.code(0).stderr(predicates::str::is_empty()).stdout(
predicate::eq("Created new list \"twobar-chat\" with primary key 2")
.trim()
.normalize(),
);
}
create_list(&conf_path);
list_lists(
&conf_path,
"- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
\"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
\"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
);
fn add_list_owner(conf: &Path) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("list")
.arg("twobar-chat")
.arg("add-list-owner")
.arg("--address")
.arg("list-owner@example.com")
.output()
.unwrap()
.assert();
output.code(0).stderr(predicates::str::is_empty()).stdout(
predicate::eq("Added new list owner [#1 2] list-owner@example.com")
.trim()
.normalize(),
);
}
add_list_owner(&conf_path);
list_lists(
&conf_path,
"- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
\"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
\"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
2)\n\tList owners:\n\t- [#1 2] list-owner@example.com\n\tPost policy: \
None\n\tSubscription policy: None",
);
fn remove_list_owner(conf: &Path) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("list")
.arg("twobar-chat")
.arg("remove-list-owner")
.arg("--pk")
.arg("1")
.output()
.unwrap()
.assert();
output.code(0).stderr(predicates::str::is_empty()).stdout(
predicate::eq("Removed list owner with pk = 1")
.trim()
.normalize(),
);
}
remove_list_owner(&conf_path);
list_lists(
&conf_path,
"- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
\"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
\"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
);
}

View File

@ -1,398 +0,0 @@
/*
* meli - email module
*
* Copyright 2019 Manos Pitsidianakis
*
* This file is part of meli.
*
* meli is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* meli is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with meli. If not, see <http://www.gnu.org/licenses/>.
*/
use assert_cmd::assert::OutputAssertExt;
use mailpot::{
melib,
models::{changesets::ListSubscriptionChangeset, *},
queue::Queue,
Configuration, Connection, SendMail,
};
use mailpot_tests::*;
use predicates::prelude::*;
use tempfile::TempDir;
fn generate_mail(from: &str, to: &str, subject: &str, body: &str, seq: &mut usize) -> String {
format!(
"From: {from}@example.com
To: <foo-chat{to}@example.com>
Subject: {subject}
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID:
<aaa{}@example.com>
Content-Language: en-US
Content-Type: text/plain
{body}
",
{
let val = *seq;
*seq += 1;
val
}
)
}
#[test]
fn test_out_queue_flush() {
use assert_cmd::Command;
let tmp_dir = TempDir::new().unwrap();
let conf_path = tmp_dir.path().join("conf.toml");
let db_path = tmp_dir.path().join("mpot.db");
let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8826").build();
let config = Configuration {
send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let config_str = config.to_toml();
std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
log::info!("Creating foo-chat@example.com mailing list.");
let post_policy;
let foo_chat = {
let db = Connection::open_or_create_db(config.clone())
.unwrap()
.trusted();
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
post_policy = db
.set_list_post_policy(PostPolicy {
pk: -1,
list: foo_chat.pk(),
announce_only: false,
subscription_only: false,
approval_needed: false,
open: true,
custom: false,
})
.unwrap();
foo_chat
};
let headers_fn = |env: &melib::Envelope| {
assert!(env.subject().starts_with(&format!("[{}] ", foo_chat.id)));
let headers = env.other_headers();
assert_eq!(
headers
.get(melib::HeaderName::LIST_ID)
.map(|header| header.to_string()),
Some(foo_chat.id_header())
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_HELP)
.map(|header| header.to_string()),
foo_chat.help_header()
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_POST)
.map(|header| header.to_string()),
foo_chat.post_header(Some(&post_policy))
);
};
log::info!("Running mpot flush-queue on empty out queue.");
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-vv")
.arg("-c")
.arg(&conf_path)
.arg("flush-queue")
.output()
.unwrap()
.assert();
output.code(0).stderr(predicates::str::is_empty()).stdout(
predicate::eq("Queue out has 0 messages.")
.trim()
.normalize(),
);
let mut seq = 0; // for generated emails
log::info!("Subscribe two users, Αλίκη and Χαραλάμπης to foo-chat.");
{
let db = Connection::open_or_create_db(config.clone())
.unwrap()
.trusted();
for who in ["Αλίκη", "Χαραλάμπης"] {
// = ["Alice", "Bob"]
let mail = generate_mail(who, "+request", "subscribe", "", &mut seq);
let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
.expect("Could not parse message");
db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
.unwrap();
}
db.update_subscription(ListSubscriptionChangeset {
list: foo_chat.pk(),
address: "Χαραλάμπης@example.com".into(),
receive_own_posts: Some(true),
..Default::default()
})
.unwrap();
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 2);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 2);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
}
log::info!("Flush out queue, subscription confirmations should be sent to the new users.");
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-vv")
.arg("-c")
.arg(&conf_path)
.arg("flush-queue")
.output()
.unwrap()
.assert();
output.code(0).stdout(
predicate::eq("Queue out has 2 messages.")
.trim()
.normalize(),
);
/* Check that confirmation emails are correct */
let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].0, "=?UTF-8?B?zpHOu86vzrrOtw==?=@example.com");
assert_eq!(
stored[1].0,
"=?UTF-8?B?zqfOsc+BzrHOu86szrzPgM63z4I=?=@example.com"
);
for item in stored.iter() {
assert_eq!(
item.1.subject(),
"[foo-chat] You have successfully subscribed to foobar chat."
);
assert_eq!(
&item.1.field_from_to_string(),
"foo-chat+request@example.com"
);
headers_fn(&item.1);
}
log::info!(
"Χαραλάμπης submits a post to list. Flush out queue, Χαραλάμπης' post should be relayed \
to Αλίκη, and Χαραλάμπης should receive a copy of their own post because of \
`receive_own_posts` setting."
);
{
let db = Connection::open_or_create_db(config.clone())
.unwrap()
.trusted();
let mail = generate_mail("Χαραλάμπης", "", "hello world", "Hello there.", &mut seq);
let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
.expect("Could not parse message");
db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
.unwrap();
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 2);
}
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-vv")
.arg("-c")
.arg(&conf_path)
.arg("flush-queue")
.output()
.unwrap()
.assert();
output.code(0).stdout(
predicate::eq("Queue out has 2 messages.")
.trim()
.normalize(),
);
/* Check that user posts are correct */
{
let db = Connection::open_or_create_db(config).unwrap().trusted();
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 0);
}
let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].0, "Αλίκη@example.com");
assert_eq!(stored[1].0, "Χαραλάμπης@example.com");
assert_eq!(stored[0].1.message_id(), stored[1].1.message_id());
assert_eq!(stored[0].1.other_headers(), stored[1].1.other_headers());
headers_fn(&stored[0].1);
}
#[test]
fn test_list_requests_submission() {
use assert_cmd::Command;
let tmp_dir = TempDir::new().unwrap();
let conf_path = tmp_dir.path().join("conf.toml");
let db_path = tmp_dir.path().join("mpot.db");
let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8827").build();
let config = Configuration {
send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let config_str = config.to_toml();
std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
log::info!("Creating foo-chat@example.com mailing list.");
let post_policy;
let foo_chat = {
let db = Connection::open_or_create_db(config.clone())
.unwrap()
.trusted();
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
post_policy = db
.set_list_post_policy(PostPolicy {
pk: -1,
list: foo_chat.pk(),
announce_only: false,
subscription_only: false,
approval_needed: false,
open: true,
custom: false,
})
.unwrap();
foo_chat
};
let headers_fn = |env: &melib::Envelope| {
let headers = env.other_headers();
assert_eq!(
headers.get(melib::HeaderName::LIST_ID),
Some(foo_chat.id_header().as_str())
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_HELP)
.map(|header| header.to_string()),
foo_chat.help_header()
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_POST)
.map(|header| header.to_string()),
foo_chat.post_header(Some(&post_policy))
);
};
log::info!("Running mpot flush-queue on empty out queue.");
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-vv")
.arg("-c")
.arg(&conf_path)
.arg("flush-queue")
.output()
.unwrap()
.assert();
output.code(0).stderr(predicates::str::is_empty()).stdout(
predicate::eq("Queue out has 0 messages.")
.trim()
.normalize(),
);
let mut seq = 0; // for generated emails
log::info!("User Αλίκη sends to foo-chat+request with subject 'help'.");
{
let db = Connection::open_or_create_db(config).unwrap().trusted();
let mail = generate_mail("Αλίκη", "+request", "help", "", &mut seq);
let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
.expect("Could not parse message");
db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
.unwrap();
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 1);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
}
log::info!("Flush out queue, help reply should go to Αλίκη.");
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-vv")
.arg("-c")
.arg(&conf_path)
.arg("flush-queue")
.output()
.unwrap()
.assert();
output.code(0).stdout(
predicate::eq("Queue out has 1 messages.")
.trim()
.normalize(),
);
/* Check that help email is correct */
let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].0, "=?UTF-8?B?zpHOu86vzrrOtw==?=@example.com");
assert_eq!(stored[0].1.subject(), "Help for foobar chat");
assert_eq!(
&stored[0].1.field_from_to_string(),
"foo-chat+request@example.com"
);
headers_fn(&stored[0].1);
}

View File

@ -1,35 +0,0 @@
[package]
name = "mailpot"
version = "0.1.1"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2021"
license = "LICENSE"
readme = "README.md"
description = "mailing list manager"
repository = "https://github.com/meli/mailpot"
keywords = ["mail", "mailing-lists"]
categories = ["email"]
[lib]
doc-scrape-examples = true
[dependencies]
anyhow = "1.0.58"
chrono = { version = "^0.4", features = ["serde", ] }
jsonschema = { version = "0.17", default-features = false }
log = "0.4"
melib = { default-features = false, features = ["mbox", "smtp", "unicode-algorithms", "maildir"], git = "https://git.meli-email.org/meli/meli.git", rev = "64e60cb" }
minijinja = { version = "0.31.0", features = ["source", ] }
percent-encoding = { version = "^2.1" }
rusqlite = { version = "^0.30", features = ["bundled", "functions", "trace", "hooks", "serde_json", "array", "chrono", "unlock_notify"] }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
thiserror = { version = "1.0.48", default-features = false }
toml = "^0.5"
xdg = "2.4.1"
[dev-dependencies]
mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
reqwest = { version = "0.11", default-features = false, features = ["json", "blocking"] }
stderrlog = { version = "^0.6" }
tempfile = { version = "3.9" }

View File

@ -1,17 +0,0 @@
# mailpot-core
Initialize `sqlite3` database
```shell
sqlite3 mpot.db < ./src/schema.sql
```
## Tests
`test_smtp_mailcrab` requires a running mailcrab instance.
You must set the environment variable `MAILCRAB_IP` to run this.
Example:
```shell
MAILCRAB_IP="127.0.0.1" cargo test mailcrab
```

View File

@ -1,110 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2023 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{fs::read_dir, io::Write, path::Path};
/// Scans migrations directory for file entries, and creates a rust file with an array containing
/// the migration slices.
///
///
/// If a migration is a data migration (not a CREATE, DROP or ALTER statement) it is appended to
/// the schema file.
///
/// Returns the current `user_version` PRAGMA value.
pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
migrations_path: M,
output_file: O,
schema_file: &mut Vec<u8>,
) -> i32 {
let migrations_folder_path = migrations_path.as_ref();
let output_file_path = output_file.as_ref();
let mut paths = vec![];
let mut undo_paths = vec![];
for entry in read_dir(migrations_folder_path).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() || path.extension().map(|os| os.to_str().unwrap()) != Some("sql") {
continue;
}
if path
.file_name()
.unwrap()
.to_str()
.unwrap()
.ends_with("undo.sql")
{
undo_paths.push(path);
} else {
paths.push(path);
}
}
paths.sort();
undo_paths.sort();
let mut migr_rs = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(output_file_path)
.unwrap();
migr_rs
.write_all(b"\n//(user_version, redo sql, undo sql\n&[")
.unwrap();
for (i, (p, u)) in paths.iter().zip(undo_paths.iter()).enumerate() {
// This should be a number string, padded with 2 zeros if it's less than 3
// digits. e.g. 001, \d{3}
let mut num = p.file_stem().unwrap().to_str().unwrap();
let is_data = num.ends_with(".data");
if is_data {
num = num.strip_suffix(".data").unwrap();
}
if !u.file_name().unwrap().to_str().unwrap().starts_with(num) {
panic!("Undo file {u:?} should match with {p:?}");
}
if num.parse::<u32>().is_err() {
panic!("Migration file {p:?} should start with a number");
}
assert_eq!(num.parse::<usize>().unwrap(), i + 1, "migration sql files should start with 1, not zero, and no intermediate numbers should be missing. Panicked on file: {}", p.display());
migr_rs.write_all(b"(").unwrap();
migr_rs
.write_all(num.trim_start_matches('0').as_bytes())
.unwrap();
migr_rs.write_all(b",r##\"").unwrap();
let redo = std::fs::read_to_string(p).unwrap();
migr_rs.write_all(redo.trim().as_bytes()).unwrap();
migr_rs.write_all(b"\"##,r##\"").unwrap();
migr_rs
.write_all(std::fs::read_to_string(u).unwrap().trim().as_bytes())
.unwrap();
migr_rs.write_all(b"\"##),").unwrap();
if is_data {
schema_file.extend(b"\n\n-- ".iter());
schema_file.extend(num.as_bytes().iter());
schema_file.extend(b".data.sql\n\n".iter());
schema_file.extend(redo.into_bytes().into_iter());
}
}
migr_rs.write_all(b"]").unwrap();
migr_rs.flush().unwrap();
paths.len() as i32
}

View File

@ -1,95 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{
fs::OpenOptions,
process::{Command, Stdio},
};
// // Source: https://stackoverflow.com/a/64535181
// fn is_output_file_outdated<P1, P2>(input: P1, output: P2) -> io::Result<bool>
// where
// P1: AsRef<Path>,
// P2: AsRef<Path>,
// {
// let out_meta = metadata(output);
// if let Ok(meta) = out_meta {
// let output_mtime = meta.modified()?;
//
// // if input file is more recent than our output, we are outdated
// let input_meta = metadata(input)?;
// let input_mtime = input_meta.modified()?;
//
// Ok(input_mtime > output_mtime)
// } else {
// // output file not found, we are outdated
// Ok(true)
// }
// }
include!("make_migrations.rs");
const MIGRATION_RS: &str = "src/migrations.rs.inc";
fn main() {
println!("cargo:rerun-if-changed=src/migrations.rs.inc");
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-changed=src/schema.sql.m4");
let mut output = Command::new("m4")
.arg("./src/schema.sql.m4")
.output()
.unwrap();
if String::from_utf8_lossy(&output.stdout).trim().is_empty() {
panic!(
"m4 output is empty. stderr was {}",
String::from_utf8_lossy(&output.stderr)
);
}
let user_version: i32 = make_migrations("migrations", MIGRATION_RS, &mut output.stdout);
let mut verify = Command::new(std::env::var("SQLITE_BIN").unwrap_or("sqlite3".into()))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
println!(
"Verifying by creating an in-memory database in sqlite3 and feeding it the output schema."
);
verify
.stdin
.take()
.unwrap()
.write_all(&output.stdout)
.unwrap();
let exit = verify.wait_with_output().unwrap();
if !exit.status.success() {
panic!(
"sqlite3 could not read SQL schema: {}",
String::from_utf8_lossy(&exit.stdout)
);
}
let mut file = std::fs::File::create("./src/schema.sql").unwrap();
file.write_all(&output.stdout).unwrap();
file.write_all(
&format!("\n\n-- Set current schema version.\n\nPRAGMA user_version = {user_version};\n")
.as_bytes(),
)
.unwrap();
}

View File

@ -1,87 +0,0 @@
import json
from pathlib import Path
import re
import sys
import pprint
import argparse
def make_undo(id: str) -> str:
return f"DELETE FROM settings_json_schema WHERE id = '{id}';"
def make_redo(id: str, value: str) -> str:
return f"""INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('{id}', '{value}');"""
class Migration:
patt = re.compile(r"(\d+)[.].*sql")
def __init__(self, path: Path):
name = path.name
self.path = path
self.is_data = "data" in name
self.is_undo = "undo" in name
m = self.patt.match(name)
self.seq = int(m.group(1))
self.name = name
def __str__(self) -> str:
return str(self.seq)
def __repr__(self) -> str:
return f"Migration(seq={self.seq},name={self.name},path={self.path},is_data={self.is_data},is_undo={self.is_undo})"
if __name__ == "__main__":
parser = argparse.ArgumentParser(
prog="Create migrations", description="", epilog=""
)
parser.add_argument("--data", action="store_true")
parser.add_argument("--settings", action="store_true")
parser.add_argument("--name", type=str, default=None)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
migrations = {}
last = -1
for f in Path(".").glob("migrations/*.sql"):
m = Migration(f)
last = max(last, m.seq)
seq = str(m)
if seq not in migrations:
if m.is_undo:
migrations[seq] = (None, m)
else:
migrations[seq] = (m, None)
else:
if m.is_undo:
redo, _ = migrations[seq]
migrations[seq] = (redo, m)
else:
_, undo = migrations[seq]
migrations[seq] = (m, undo)
# pprint.pprint(migrations)
if args.data:
data = ".data"
else:
data = ""
new_name = f"{last+1:0>3}{data}.sql"
new_undo_name = f"{last+1:0>3}{data}.undo.sql"
if not args.dry_run:
redo = ""
undo = ""
if args.settings:
if not args.name:
print("Please define a --name.")
sys.exit(1)
redo = make_redo(args.name, "{}")
undo = make_undo(args.name)
name = args.name.lower() + ".json"
with open(Path("settings_json_schemas") / name, "x") as file:
file.write("{}")
with open(Path("migrations") / new_name, "x") as file, open(
Path("migrations") / new_undo_name, "x"
) as undo_file:
file.write(redo)
undo_file.write(undo)
print(f"Created to {new_name} and {new_undo_name}.")

View File

@ -1,2 +0,0 @@
PRAGMA foreign_keys=ON;
ALTER TABLE templates RENAME TO template;

View File

@ -1,2 +0,0 @@
PRAGMA foreign_keys=ON;
ALTER TABLE template RENAME TO templates;

View File

@ -1,2 +0,0 @@
PRAGMA foreign_keys=ON;
ALTER TABLE list ADD COLUMN topics JSON NOT NULL CHECK (json_type(topics) == 'array') DEFAULT '[]';

View File

@ -1,2 +0,0 @@
PRAGMA foreign_keys=ON;
ALTER TABLE list DROP COLUMN topics;

View File

@ -1,20 +0,0 @@
PRAGMA foreign_keys=ON;
UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk;
CREATE TRIGGER
IF NOT EXISTS sort_topics_update_trigger
AFTER UPDATE ON list
FOR EACH ROW
WHEN NEW.topics != OLD.topics
BEGIN
UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS sort_topics_new_trigger
AFTER INSERT ON list
FOR EACH ROW
BEGIN
UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;

View File

@ -1,4 +0,0 @@
PRAGMA foreign_keys=ON;
DROP TRIGGER sort_topics_update_trigger;
DROP TRIGGER sort_topics_new_trigger;

View File

@ -1,167 +0,0 @@
CREATE TABLE IF NOT EXISTS settings_json_schema (
pk INTEGER PRIMARY KEY NOT NULL,
id TEXT NOT NULL UNIQUE,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS list_settings_json (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
list INTEGER,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
UNIQUE (list, name) ON CONFLICT ROLLBACK
);
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_update
AFTER UPDATE OF value, name, is_valid ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_insert
AFTER INSERT ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS invalidate_settings_json_on_schema_update
AFTER UPDATE OF value, id ON settings_json_schema
FOR EACH ROW
BEGIN
UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
END;
DROP TRIGGER IF EXISTS last_modified_list;
DROP TRIGGER IF EXISTS last_modified_owner;
DROP TRIGGER IF EXISTS last_modified_post_policy;
DROP TRIGGER IF EXISTS last_modified_subscription_policy;
DROP TRIGGER IF EXISTS last_modified_subscription;
DROP TRIGGER IF EXISTS last_modified_account;
DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
DROP TRIGGER IF EXISTS last_modified_template;
DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
DROP TRIGGER IF EXISTS last_modified_list_settings_json;
-- [tag:last_modified_list]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_list
AFTER UPDATE ON list
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE list SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_owner]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_owner
AFTER UPDATE ON owner
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE owner SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_post_policy]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_post_policy
AFTER UPDATE ON post_policy
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE post_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_subscription_policy]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_subscription_policy
AFTER UPDATE ON subscription_policy
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE subscription_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_subscription]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_subscription
AFTER UPDATE ON subscription
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_account]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_account
AFTER UPDATE ON account
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE account SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_candidate_subscription]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_candidate_subscription
AFTER UPDATE ON candidate_subscription
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE candidate_subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_template]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_template
AFTER UPDATE ON template
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE template SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_settings_json_schema]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_settings_json_schema
AFTER UPDATE ON settings_json_schema
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE settings_json_schema SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_list_settings_json]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_list_settings_json
AFTER UPDATE ON list_settings_json
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE list_settings_json SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;

View File

@ -1,2 +0,0 @@
DROP TABLE settings_json_schema;
DROP TABLE list_settings_json;

View File

@ -1,31 +0,0 @@
INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/ArchivedAtLinkSettings",
"$defs": {
"ArchivedAtLinkSettings": {
"title": "ArchivedAtLinkSettings",
"description": "Settings for ArchivedAtLink message filter",
"type": "object",
"properties": {
"template": {
"title": "Jinja template for header value",
"description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
"examples": [
"https://www.example.com/{{msg_id}}",
"https://www.example.com/{{msg_id}}.html"
],
"type": "string",
"pattern": ".+[{][{]msg_id[}][}].*"
},
"preserve_carets": {
"title": "Preserve carets of `Message-ID` in generated value",
"type": "boolean",
"default": false
}
},
"required": [
"template"
]
}
}
}');

View File

@ -1 +0,0 @@
DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';

View File

@ -1,20 +0,0 @@
INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/AddSubjectTagPrefixSettings",
"$defs": {
"AddSubjectTagPrefixSettings": {
"title": "AddSubjectTagPrefixSettings",
"description": "Settings for AddSubjectTagPrefix message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, the list subject prefix is added to post subjects.",
"type": "boolean"
}
},
"required": [
"enabled"
]
}
}
}');

View File

@ -1 +0,0 @@
DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';

View File

@ -1,33 +0,0 @@
INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/MimeRejectSettings",
"$defs": {
"MimeRejectSettings": {
"title": "MimeRejectSettings",
"description": "Settings for MimeReject message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, list posts that contain mime types in the reject array are rejected.",
"type": "boolean"
},
"reject": {
"title": "Mime types to reject.",
"type": "array",
"minLength": 0,
"items": { "$ref": "#/$defs/MimeType" }
},
"required": [
"enabled"
]
}
},
"MimeType": {
"type": "string",
"maxLength": 127,
"minLength": 3,
"uniqueItems": true,
"pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
}
}
}');

View File

@ -1 +0,0 @@
DELETE FROM settings_json_schema WHERE id = 'MimeRejectSettings';

View File

@ -1 +0,0 @@
../rustfmt.toml

View File

@ -1,20 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/AddSubjectTagPrefixSettings",
"$defs": {
"AddSubjectTagPrefixSettings": {
"title": "AddSubjectTagPrefixSettings",
"description": "Settings for AddSubjectTagPrefix message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, the list subject prefix is added to post subjects.",
"type": "boolean"
}
},
"required": [
"enabled"
]
}
}
}

View File

@ -1,31 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/ArchivedAtLinkSettings",
"$defs": {
"ArchivedAtLinkSettings": {
"title": "ArchivedAtLinkSettings",
"description": "Settings for ArchivedAtLink message filter",
"type": "object",
"properties": {
"template": {
"title": "Jinja template for header value",
"description": "Template for `Archived-At` header value, as described in RFC 5064 \"The Archived-At Message Header Field\". The template receives only one string variable with the value of the mailing list post `Message-ID` header.\n\nFor example, if:\n\n- the template is `http://www.example.com/mid/{{msg_id}}`\n- the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\nThe full header will be generated as:\n\n`Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\nNote: Surrounding carets in the `Message-ID` value are not required. If you wish to preserve them in the URL, set option `preserve-carets` to true.",
"examples": [
"https://www.example.com/{{msg_id}}",
"https://www.example.com/{{msg_id}}.html"
],
"type": "string",
"pattern": ".+[{][{]msg_id[}][}].*"
},
"preserve_carets": {
"title": "Preserve carets of `Message-ID` in generated value",
"type": "boolean",
"default": false
}
},
"required": [
"template"
]
}
}
}

View File

@ -1,33 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/MimeRejectSettings",
"$defs": {
"MimeRejectSettings": {
"title": "MimeRejectSettings",
"description": "Settings for MimeReject message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, list posts that contain mime types in the reject array are rejected.",
"type": "boolean"
},
"reject": {
"title": "Mime types to reject.",
"type": "array",
"minLength": 0,
"items": { "$ref": "#/$defs/MimeType" }
},
"required": [
"enabled"
]
}
},
"MimeType": {
"type": "string",
"maxLength": 127,
"minLength": 3,
"uniqueItems": true,
"pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
}
}
}

View File

@ -1,167 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{
io::{Read, Write},
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
};
use chrono::prelude::*;
use super::errors::*;
/// How to send e-mail.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type", content = "value")]
pub enum SendMail {
/// A `melib` configuration for talking to an SMTP server.
Smtp(melib::smtp::SmtpServerConf),
/// A plain shell command passed to `sh -c` with the e-mail passed in the
/// stdin.
ShellCommand(String),
}
/// The configuration for the mailpot database and the mail server.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Configuration {
/// How to send e-mail.
pub send_mail: SendMail,
/// The location of the sqlite3 file.
pub db_path: PathBuf,
/// The directory where data are stored.
pub data_path: PathBuf,
/// Instance administrators (List of e-mail addresses). Optional.
#[serde(default)]
pub administrators: Vec<String>,
}
impl Configuration {
/// Create a new configuration value from a given database path value.
///
/// If you wish to create a new database with this configuration, use
/// [`Connection::open_or_create_db`](crate::Connection::open_or_create_db).
/// To open an existing database, use
/// [`Database::open_db`](crate::Connection::open_db).
pub fn new(db_path: impl Into<PathBuf>) -> Self {
let db_path = db_path.into();
Self {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
data_path: db_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| db_path.clone()),
administrators: vec![],
db_path,
}
}
/// Deserialize configuration from TOML file.
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let mut s = String::new();
let mut file = std::fs::File::open(path)
.with_context(|| format!("Configuration file {} not found.", path.display()))?;
file.read_to_string(&mut s)
.with_context(|| format!("Could not read from file {}.", path.display()))?;
let config: Self = toml::from_str(&s)
.map_err(anyhow::Error::from)
.with_context(|| {
format!(
"Could not parse configuration file `{}` successfully: ",
path.display()
)
})?;
Ok(config)
}
/// The saved data path.
pub fn data_directory(&self) -> &Path {
self.data_path.as_path()
}
/// The sqlite3 database path.
pub fn db_path(&self) -> &Path {
self.db_path.as_path()
}
/// Save message to a custom path.
pub fn save_message_to_path(&self, msg: &str, mut path: PathBuf) -> Result<PathBuf> {
if path.is_dir() {
let now = Local::now().timestamp();
path.push(format!("{}-failed.eml", now));
}
debug_assert!(path != self.db_path());
let mut file = std::fs::File::create(&path)
.with_context(|| format!("Could not create file {}.", path.display()))?;
let metadata = file
.metadata()
.with_context(|| format!("Could not fstat file {}.", path.display()))?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600); // Read/write for owner only.
file.set_permissions(permissions)
.with_context(|| format!("Could not chmod 600 file {}.", path.display()))?;
file.write_all(msg.as_bytes())
.with_context(|| format!("Could not write message to file {}.", path.display()))?;
file.flush()
.with_context(|| format!("Could not flush message I/O to file {}.", path.display()))?;
Ok(path)
}
/// Save message to the data directory.
pub fn save_message(&self, msg: String) -> Result<PathBuf> {
self.save_message_to_path(&msg, self.data_directory().to_path_buf())
}
/// Serialize configuration to a TOML string.
pub fn to_toml(&self) -> String {
toml::Value::try_from(self)
.expect("Could not serialize config to TOML")
.to_string()
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn test_config_parse_error() {
let tmp_dir = TempDir::new().unwrap();
let conf_path = tmp_dir.path().join("conf.toml");
std::fs::write(&conf_path, b"afjsad skas as a as\n\n\n\n\t\x11\n").unwrap();
assert_eq!(
Configuration::from_file(&conf_path)
.unwrap_err()
.display_chain()
.to_string(),
format!(
"[1] Could not parse configuration file `{}` successfully: Caused by:\n[2] \
Error: expected an equals, found an identifier at line 1 column 8\n",
conf_path.display()
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +0,0 @@
# use mailpot::{*, models::*};
# use melib::smtp::{SmtpServerConf, SmtpAuth, SmtpSecurity};
#
# use tempfile::TempDir;
#
# let tmp_dir = TempDir::new()?;
# let db_path = tmp_dir.path().join("mpot.db");
# let data_path = tmp_dir.path().to_path_buf();
# let config = Configuration {
# send_mail: mailpot::SendMail::Smtp(
# SmtpServerConf {
# hostname: "127.0.0.1".into(),
# port: 25,
# envelope_from: "foo-chat@example.com".into(),
# auth: SmtpAuth::None,
# security: SmtpSecurity::None,
# extensions: Default::default(),
# }
# ),
# db_path,
# data_path,
# administrators: vec![],
# };
# let db = Connection::open_or_create_db(config)?.trusted();
# let list = db
# .create_list(MailingList {
# pk: 5,
# name: "foobar chat".into(),
# id: "foo-chat".into(),
# address: "foo-chat@example.com".into(),
# description: Some("Hello world, from foo-chat list".into()),
# topics: vec![],
# archive_url: Some("https://lists.example.com".into()),
# })
# .unwrap();
# let sub_policy = SubscriptionPolicy {
# pk: 1,
# list: 5,
# send_confirmation: true,
# open: false,
# manual: false,
# request: true,
# custom: false,
# };
# let post_policy = PostPolicy {
# pk: 1,
# list: 5,
# announce_only: false,
# subscription_only: false,
# approval_needed: false,
# open: true,
# custom: false,
# };

View File

@ -1,232 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Errors of this library.
use std::sync::Arc;
use thiserror::Error;
/// Mailpot library error.
#[derive(Error, Debug)]
pub struct Error {
kind: ErrorKind,
source: Option<Arc<Self>>,
}
/// Mailpot library error.
#[derive(Error, Debug)]
pub enum ErrorKind {
/// Post rejected.
#[error("Your post has been rejected: {0}")]
PostRejected(String),
/// An entry was not found in the database.
#[error("This {0} is not present in the database.")]
NotFound(&'static str),
/// A request was invalid.
#[error("Your list request has been found invalid: {0}.")]
InvalidRequest(String),
/// An error happened and it was handled internally.
#[error("An error happened and it was handled internally: {0}.")]
Information(String),
/// An error that shouldn't happen and should be reported.
#[error("An error that shouldn't happen and should be reported: {0}.")]
Bug(String),
/// Error returned from an external user initiated operation such as
/// deserialization or I/O.
#[error("Error: {0}")]
External(#[from] anyhow::Error),
/// Generic
#[error("{0}")]
Generic(anyhow::Error),
/// Error returned from sqlite3.
#[error("Error returned from sqlite3: {0}.")]
Sql(
#[from]
#[source]
rusqlite::Error,
),
/// Error returned from sqlite3.
#[error("Error returned from sqlite3: {0}")]
SqlLib(
#[from]
#[source]
rusqlite::ffi::Error,
),
/// Error returned from internal I/O operations.
#[error("Error returned from internal I/O operation: {0}")]
Io(#[from] ::std::io::Error),
/// Error returned from e-mail protocol operations from `melib` crate.
#[error("Error returned from e-mail protocol operations from `melib` crate: {0}")]
Melib(#[from] melib::error::Error),
/// Error from deserializing JSON values.
#[error("Error from deserializing JSON values: {0}")]
SerdeJson(#[from] serde_json::Error),
/// Error returned from minijinja template engine.
#[error("Error returned from minijinja template engine: {0}")]
Template(#[from] minijinja::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.kind)
}
}
impl From<ErrorKind> for Error {
fn from(kind: ErrorKind) -> Self {
Self { kind, source: None }
}
}
macro_rules! impl_from {
($ty:ty) => {
impl From<$ty> for Error {
fn from(err: $ty) -> Self {
Self {
kind: err.into(),
source: None,
}
}
}
};
}
impl_from! { anyhow::Error }
impl_from! { rusqlite::Error }
impl_from! { rusqlite::ffi::Error }
impl_from! { ::std::io::Error }
impl_from! { melib::error::Error }
impl_from! { serde_json::Error }
impl_from! { minijinja::Error }
impl Error {
/// Helper function to create a new generic error message.
pub fn new_external<S: Into<String>>(msg: S) -> Self {
let msg = msg.into();
ErrorKind::External(anyhow::Error::msg(msg)).into()
}
/// Chain an error by introducing a new head of the error chain.
pub fn chain_err<E>(self, lambda: impl FnOnce() -> E) -> Self
where
E: Into<Self>,
{
let new_head: Self = lambda().into();
Self {
source: Some(Arc::new(self)),
..new_head
}
}
/// Insert a source error into this Error.
pub fn with_source<E>(self, source: E) -> Self
where
E: Into<Self>,
{
Self {
source: Some(Arc::new(source.into())),
..self
}
}
/// Getter for the kind field.
pub fn kind(&self) -> &ErrorKind {
&self.kind
}
/// Display error chain to user.
pub fn display_chain(&'_ self) -> impl std::fmt::Display + '_ {
ErrorChainDisplay {
current: self,
counter: 1,
}
}
}
impl From<String> for Error {
fn from(s: String) -> Self {
ErrorKind::Generic(anyhow::Error::msg(s)).into()
}
}
impl From<&str> for Error {
fn from(s: &str) -> Self {
ErrorKind::Generic(anyhow::Error::msg(s.to_string())).into()
}
}
/// Type alias for Mailpot library Results.
pub type Result<T> = std::result::Result<T, Error>;
struct ErrorChainDisplay<'e> {
current: &'e Error,
counter: usize,
}
impl std::fmt::Display for ErrorChainDisplay<'_> {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
if let Some(ref source) = self.current.source {
writeln!(fmt, "[{}] {} Caused by:", self.counter, self.current.kind)?;
Self {
current: source,
counter: self.counter + 1,
}
.fmt(fmt)
} else {
writeln!(fmt, "[{}] {}", self.counter, self.current.kind)?;
Ok(())
}
}
}
/// adfsa
pub trait Context<T> {
/// Wrap the error value with additional context.
fn context<C>(self, context: C) -> Result<T>
where
C: Into<Error>;
/// Wrap the error value with additional context that is evaluated lazily
/// only once an error does occur.
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: Into<Error>,
F: FnOnce() -> C;
}
impl<T, E> Context<T> for std::result::Result<T, E>
where
Error: From<E>,
{
fn context<C>(self, context: C) -> Result<T>
where
C: Into<Error>,
{
self.map_err(|err| Error::from(err).chain_err(|| context.into()))
}
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: Into<Error>,
F: FnOnce() -> C,
{
self.map_err(|err| Error::from(err).chain_err(|| f().into()))
}
}

View File

@ -1,259 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#![deny(
missing_docs,
rustdoc::broken_intra_doc_links,
/* groups */
clippy::correctness,
clippy::suspicious,
clippy::complexity,
clippy::perf,
clippy::style,
clippy::cargo,
clippy::nursery,
/* restriction */
clippy::dbg_macro,
clippy::rc_buffer,
clippy::as_underscore,
clippy::assertions_on_result_states,
/* pedantic */
clippy::cast_lossless,
clippy::cast_possible_wrap,
clippy::ptr_as_ptr,
clippy::bool_to_int_with_if,
clippy::borrow_as_ptr,
clippy::case_sensitive_file_extension_comparisons,
clippy::cast_lossless,
clippy::cast_ptr_alignment,
clippy::naive_bytecount
)]
#![allow(clippy::multiple_crate_versions, clippy::missing_const_for_fn)]
//! Mailing list manager library.
//!
//! Data is stored in a `sqlite3` database.
//! You can inspect the schema in [`SCHEMA`](crate::Connection::SCHEMA).
//!
//! # Usage
//!
//! `mailpot` can be used with the CLI tool in [`mailpot-cli`](mailpot-cli),
//! and/or in the web interface of the [`mailpot-web`](mailpot-web) crate.
//!
//! You can also directly use this crate as a library.
//!
//! # Example
//!
//! ```
//! use mailpot::{models::*, Configuration, Connection, SendMail};
//! # use tempfile::TempDir;
//!
//! # let tmp_dir = TempDir::new().unwrap();
//! # let db_path = tmp_dir.path().join("mpot.db");
//! # let config = Configuration {
//! # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
//! # db_path: db_path.clone(),
//! # data_path: tmp_dir.path().to_path_buf(),
//! # administrators: vec![],
//! # };
//! #
//! # fn do_test(config: Configuration) -> mailpot::Result<()> {
//! let db = Connection::open_or_create_db(config)?.trusted();
//!
//! // Create a new mailing list
//! let list_pk = db
//! .create_list(MailingList {
//! pk: 0,
//! name: "foobar chat".into(),
//! id: "foo-chat".into(),
//! address: "foo-chat@example.com".into(),
//! topics: vec![],
//! description: None,
//! archive_url: None,
//! })?
//! .pk;
//!
//! db.set_list_post_policy(PostPolicy {
//! pk: 0,
//! list: list_pk,
//! announce_only: false,
//! subscription_only: true,
//! approval_needed: false,
//! open: false,
//! custom: false,
//! })?;
//!
//! // Drop privileges; we can only process new e-mail and modify subscriptions from now on.
//! let mut db = db.untrusted();
//!
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
//!
//! // Process a subscription request e-mail
//! let subscribe_bytes = b"From: Name <user@example.com>
//! To: <foo-chat+subscribe@example.com>
//! Subject: subscribe
//! Date: Thu, 29 Oct 2020 13:58:16 +0000
//! Message-ID: <1@example.com>
//!
//! ";
//! let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
//! db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
//!
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
//!
//! // Process a post
//! let post_bytes = b"From: Name <user@example.com>
//! To: <foo-chat@example.com>
//! Subject: my first post
//! Date: Thu, 29 Oct 2020 14:01:09 +0000
//! Message-ID: <2@example.com>
//!
//! Hello
//! ";
//! let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
//! db.post(&envelope, post_bytes, /* dry_run */ false)?;
//!
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
//! # Ok(())
//! # }
//! # do_test(config);
//! ```
/* Annotations:
*
* Global tags (in tagref format <https://github.com/stepchowfun/tagref>) for source code
* annotation:
*
* - [tag:needs_unit_test]
* - [tag:needs_user_doc]
* - [tag:needs_dev_doc]
* - [tag:FIXME]
* - [tag:TODO]
* - [tag:VERIFY] Verify whether this is the correct way to do something
*/
/// Error library
pub extern crate anyhow;
/// Date library
pub extern crate chrono;
/// Sql library
pub extern crate rusqlite;
/// Alias for [`chrono::DateTime<chrono::Utc>`].
pub type DateTime = chrono::DateTime<chrono::Utc>;
/// Serde
#[macro_use]
pub extern crate serde;
/// Log
pub extern crate log;
/// melib
pub extern crate melib;
/// serde_json
pub extern crate serde_json;
mod config;
mod connection;
mod errors;
pub mod mail;
pub mod message_filters;
pub mod models;
pub mod policies;
#[cfg(not(target_os = "windows"))]
pub mod postfix;
pub mod posts;
pub mod queue;
pub mod submission;
pub mod subscriptions;
mod templates;
pub use config::{Configuration, SendMail};
pub use connection::{transaction, *};
pub use errors::*;
use models::*;
pub use templates::*;
/// A `mailto:` value.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MailtoAddress {
/// E-mail address.
pub address: String,
/// Optional subject value.
pub subject: Option<String>,
}
#[doc = include_str!("../../README.md")]
#[cfg(doctest)]
pub struct ReadmeDoctests;
/// Trait for stripping carets ('<','>') from Message IDs.
pub trait StripCarets {
/// If `self` is surrounded by carets, strip them.
fn strip_carets(&self) -> &str;
}
impl StripCarets for &str {
fn strip_carets(&self) -> &str {
let mut self_ref = self.trim();
if self_ref.starts_with('<') && self_ref.ends_with('>') {
self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
}
self_ref
}
}
/// Trait for stripping carets ('<','>') from Message IDs inplace.
pub trait StripCaretsInplace {
/// If `self` is surrounded by carets, strip them.
fn strip_carets_inplace(self) -> Self;
}
impl StripCaretsInplace for &str {
fn strip_carets_inplace(self) -> Self {
let mut self_ref = self.trim();
if self_ref.starts_with('<') && self_ref.ends_with('>') {
self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
}
self_ref
}
}
impl StripCaretsInplace for String {
fn strip_carets_inplace(mut self) -> Self {
if self.starts_with('<') && self.ends_with('>') {
self.drain(0..1);
let len = self.len();
self.drain(len.saturating_sub(1)..len);
}
self
}
}
use percent_encoding::CONTROLS;
pub use percent_encoding::{utf8_percent_encode, AsciiSet};
// from https://github.com/servo/rust-url/blob/master/url/src/parser.rs
const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
/// Set for percent encoding URL components.
pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');

View File

@ -1,181 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Types for processing new posts:
//! [`PostFilter`](crate::message_filters::PostFilter), [`ListContext`],
//! [`MailJob`] and [`PostAction`].
use std::collections::HashMap;
use log::trace;
use melib::{Address, MessageID};
use crate::{
models::{ListOwner, ListSubscription, MailingList, PostPolicy, SubscriptionPolicy},
DbVal,
};
/// Post action returned from a list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
#[derive(Debug)]
pub enum PostAction {
/// Add to `hold` queue.
Hold,
/// Accept to mailing list.
Accept,
/// Reject and send rejection response to submitter.
Reject {
/// Human readable reason for rejection.
reason: String,
},
/// Add to `deferred` queue.
Defer {
/// Human readable reason for deferring.
reason: String,
},
}
/// List context passed to a list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
#[derive(Debug)]
pub struct ListContext<'list> {
/// Which mailing list a post was addressed to.
pub list: &'list MailingList,
/// The mailing list owners.
pub list_owners: &'list [DbVal<ListOwner>],
/// The mailing list subscriptions.
pub subscriptions: &'list [DbVal<ListSubscription>],
/// The mailing list post policy.
pub post_policy: Option<DbVal<PostPolicy>>,
/// The mailing list subscription policy.
pub subscription_policy: Option<DbVal<SubscriptionPolicy>>,
/// The scheduled jobs added by each filter in a list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
pub scheduled_jobs: Vec<MailJob>,
/// Saved settings for message filters, which process a
/// received e-mail before taking a final decision/action.
pub filter_settings: HashMap<String, DbVal<serde_json::Value>>,
}
/// Post to be considered by the list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
pub struct PostEntry {
/// `From` address of post.
pub from: Address,
/// Raw bytes of post.
pub bytes: Vec<u8>,
/// `To` addresses of post.
pub to: Vec<Address>,
/// Final action set by each filter in a list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
pub action: PostAction,
/// Post's Message-ID
pub message_id: MessageID,
}
impl core::fmt::Debug for PostEntry {
fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::fmt::Result {
fmt.debug_struct(stringify!(PostEntry))
.field("from", &self.from)
.field("message_id", &self.message_id)
.field("bytes", &format_args!("{} bytes", self.bytes.len()))
.field("to", &self.to.as_slice())
.field("action", &self.action)
.finish()
}
}
/// Scheduled jobs added to a [`ListContext`] by a list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
#[derive(Debug)]
pub enum MailJob {
/// Send post to recipients.
Send {
/// The post recipients addresses.
recipients: Vec<Address>,
},
/// Send error to submitter.
Error {
/// Human readable description of the error.
description: String,
},
/// Store post in digest for recipients.
StoreDigest {
/// The digest recipients addresses.
recipients: Vec<Address>,
},
/// Reply with subscription confirmation to submitter.
ConfirmSubscription {
/// The submitter address.
recipient: Address,
},
/// Reply with unsubscription confirmation to submitter.
ConfirmUnsubscription {
/// The submitter address.
recipient: Address,
},
}
/// Type of mailing list request.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum ListRequest {
/// Get help about a mailing list and its available interfaces.
Help,
/// Request subscription.
Subscribe,
/// Request removal of subscription.
Unsubscribe,
/// Request reception of list posts from a month-year range, inclusive.
RetrieveArchive(String, String),
/// Request reception of specific mailing list posts from `Message-ID`
/// values.
RetrieveMessages(Vec<String>),
/// Request change in subscription settings.
/// See [`ListSubscription`].
ChangeSetting(String, bool),
/// Other type of request.
Other(String),
}
impl std::fmt::Display for ListRequest {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl<S: AsRef<str>> TryFrom<(S, &melib::Envelope)> for ListRequest {
type Error = crate::Error;
fn try_from((val, env): (S, &melib::Envelope)) -> std::result::Result<Self, Self::Error> {
let val = val.as_ref();
Ok(match val {
"subscribe" => Self::Subscribe,
"request" if env.subject().trim() == "subscribe" => Self::Subscribe,
"unsubscribe" => Self::Unsubscribe,
"request" if env.subject().trim() == "unsubscribe" => Self::Unsubscribe,
"help" => Self::Help,
"request" if env.subject().trim() == "help" => Self::Help,
"request" => Self::Other(env.subject().trim().to_string()),
_ => {
// [ref:TODO] add ChangeSetting parsing
trace!("unknown action = {} for addresses {:?}", val, env.from(),);
Self::Other(val.trim().to_string())
}
})
}
}

View File

@ -1,406 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#![allow(clippy::result_unit_err)]
//! Filters to pass each mailing list post through. Filters are functions that
//! implement the [`PostFilter`] trait that can:
//!
//! - transform post content.
//! - modify the final [`PostAction`] to take.
//! - modify the final scheduled jobs to perform. (See [`MailJob`]).
//!
//! Filters are executed in sequence like this:
//!
//! ```ignore
//! let result = filters
//! .into_iter()
//! .fold(Ok((&mut post, &mut list_ctx)), |p, f| {
//! p.and_then(|(p, c)| f.feed(p, c))
//! });
//! ```
//!
//! so the processing stops at the first returned error.
mod settings;
use log::trace;
use melib::{Address, HeaderName};
use percent_encoding::utf8_percent_encode;
use crate::{
mail::{ListContext, MailJob, PostAction, PostEntry},
models::{DbVal, MailingList},
Connection, StripCarets, PATH_SEGMENT,
};
impl Connection {
/// Return the post filters of a mailing list.
pub fn list_filters(&self, _list: &DbVal<MailingList>) -> Vec<Box<dyn PostFilter>> {
vec![
Box::new(PostRightsCheck),
Box::new(MimeReject),
Box::new(FixCRLF),
Box::new(AddListHeaders),
Box::new(ArchivedAtLink),
Box::new(AddSubjectTagPrefix),
Box::new(FinalizeRecipients),
]
}
}
/// Filter that modifies and/or verifies a post candidate. On rejection, return
/// a string describing the error and optionally set `post.action` to `Reject`
/// or `Defer`
pub trait PostFilter {
/// Feed post into the filter. Perform modifications to `post` and / or
/// `ctx`, and return them with `Result::Ok` unless you want to the
/// processing to stop and return an `Result::Err`.
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()>;
}
/// Check that submitter can post to list, for now it accepts everything.
pub struct PostRightsCheck;
impl PostFilter for PostRightsCheck {
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
trace!("Running PostRightsCheck filter");
if let Some(ref policy) = ctx.post_policy {
if policy.announce_only {
trace!("post policy is announce_only");
let owner_addresses = ctx
.list_owners
.iter()
.map(|lo| lo.address())
.collect::<Vec<Address>>();
trace!("Owner addresses are: {:#?}", &owner_addresses);
trace!("Envelope from is: {:?}", &post.from);
if !owner_addresses.iter().any(|addr| *addr == post.from) {
trace!("Envelope From does not include any owner");
post.action = PostAction::Reject {
reason: "You are not allowed to post on this list.".to_string(),
};
return Err(());
}
} else if policy.subscription_only {
trace!("post policy is subscription_only");
let email_from = post.from.get_email();
trace!("post from is {:?}", &email_from);
trace!("post subscriptions are {:#?}", &ctx.subscriptions);
if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
trace!("Envelope from is not subscribed to this list");
post.action = PostAction::Reject {
reason: "Only subscriptions can post to this list.".to_string(),
};
return Err(());
}
} else if policy.approval_needed {
trace!("post policy says approval_needed");
let email_from = post.from.get_email();
trace!("post from is {:?}", &email_from);
trace!("post subscriptions are {:#?}", &ctx.subscriptions);
if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
trace!("Envelope from is not subscribed to this list");
post.action = PostAction::Defer {
reason: "Your posting has been deferred. Approval from the list's \
moderators is required before it is submitted."
.to_string(),
};
return Err(());
}
}
}
Ok((post, ctx))
}
}
/// Ensure message contains only `\r\n` line terminators, required by SMTP.
pub struct FixCRLF;
impl PostFilter for FixCRLF {
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
trace!("Running FixCRLF filter");
use std::io::prelude::*;
let mut new_vec = Vec::with_capacity(post.bytes.len());
for line in post.bytes.lines() {
new_vec.extend_from_slice(line.unwrap().as_bytes());
new_vec.extend_from_slice(b"\r\n");
}
post.bytes = new_vec;
Ok((post, ctx))
}
}
/// Add `List-*` headers
pub struct AddListHeaders;
impl PostFilter for AddListHeaders {
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
trace!("Running AddListHeaders filter");
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
let sender = format!("<{}>", ctx.list.address);
headers.push((HeaderName::SENDER, sender.as_bytes()));
let list_id = Some(ctx.list.id_header());
let list_help = ctx.list.help_header();
let list_post = ctx.list.post_header(ctx.post_policy.as_deref());
let list_unsubscribe = ctx
.list
.unsubscribe_header(ctx.subscription_policy.as_deref());
let list_subscribe = ctx
.list
.subscribe_header(ctx.subscription_policy.as_deref());
let list_archive = ctx.list.archive_header();
for (hdr, val) in [
(HeaderName::LIST_ID, &list_id),
(HeaderName::LIST_HELP, &list_help),
(HeaderName::LIST_POST, &list_post),
(HeaderName::LIST_UNSUBSCRIBE, &list_unsubscribe),
(HeaderName::LIST_SUBSCRIBE, &list_subscribe),
(HeaderName::LIST_ARCHIVE, &list_archive),
] {
if let Some(val) = val {
headers.push((hdr, val.as_bytes()));
}
}
let mut new_vec = Vec::with_capacity(
headers
.iter()
.map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
.sum::<usize>()
+ "\r\n\r\n".len()
+ body.len(),
);
for (h, v) in headers {
new_vec.extend_from_slice(h.as_str().as_bytes());
new_vec.extend_from_slice(b": ");
new_vec.extend_from_slice(v);
new_vec.extend_from_slice(b"\r\n");
}
new_vec.extend_from_slice(b"\r\n\r\n");
new_vec.extend_from_slice(body);
post.bytes = new_vec;
Ok((post, ctx))
}
}
/// Add List ID prefix in Subject header (e.g. `[list-id] ...`)
pub struct AddSubjectTagPrefix;
impl PostFilter for AddSubjectTagPrefix {
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
if let Some(mut settings) = ctx.filter_settings.remove("AddSubjectTagPrefixSettings") {
let map = settings.as_object_mut().unwrap();
let enabled = serde_json::from_value::<bool>(map.remove("enabled").unwrap()).unwrap();
if !enabled {
trace!(
"AddSubjectTagPrefix is disabled from settings found for list.pk = {} \
skipping filter",
ctx.list.pk
);
return Ok((post, ctx));
}
}
trace!("Running AddSubjectTagPrefix filter");
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
let mut subject;
if let Some((_, subj_val)) = headers.iter_mut().find(|(k, _)| k == HeaderName::SUBJECT) {
subject = format!("[{}] ", ctx.list.id).into_bytes();
subject.extend(subj_val.iter().cloned());
*subj_val = subject.as_slice();
} else {
subject = format!("[{}] (no subject)", ctx.list.id).into_bytes();
headers.push((HeaderName::SUBJECT, subject.as_slice()));
}
let mut new_vec = Vec::with_capacity(
headers
.iter()
.map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
.sum::<usize>()
+ "\r\n\r\n".len()
+ body.len(),
);
for (h, v) in headers {
new_vec.extend_from_slice(h.as_str().as_bytes());
new_vec.extend_from_slice(b": ");
new_vec.extend_from_slice(v);
new_vec.extend_from_slice(b"\r\n");
}
new_vec.extend_from_slice(b"\r\n\r\n");
new_vec.extend_from_slice(body);
post.bytes = new_vec;
Ok((post, ctx))
}
}
/// Adds `Archived-At` field, if configured.
pub struct ArchivedAtLink;
impl PostFilter for ArchivedAtLink {
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
let Some(mut settings) = ctx.filter_settings.remove("ArchivedAtLinkSettings") else {
trace!(
"No ArchivedAtLink settings found for list.pk = {} skipping filter",
ctx.list.pk
);
return Ok((post, ctx));
};
trace!("Running ArchivedAtLink filter");
let map = settings.as_object_mut().unwrap();
let template = serde_json::from_value::<String>(map.remove("template").unwrap()).unwrap();
let preserve_carets =
serde_json::from_value::<bool>(map.remove("preserve_carets").unwrap()).unwrap();
let env = minijinja::Environment::new();
let message_id = post.message_id.to_string();
let header_val = env
.render_named_str(
"ArchivedAtLinkSettings.template",
&template,
&if preserve_carets {
minijinja::context! {
msg_id => utf8_percent_encode(message_id.as_str(), PATH_SEGMENT).to_string()
}
} else {
minijinja::context! {
msg_id => utf8_percent_encode(message_id.as_str().strip_carets(), PATH_SEGMENT).to_string()
}
},
)
.map_err(|err| {
log::error!("ArchivedAtLink: {}", err);
})?;
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
headers.push((HeaderName::ARCHIVED_AT, header_val.as_bytes()));
let mut new_vec = Vec::with_capacity(
headers
.iter()
.map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
.sum::<usize>()
+ "\r\n\r\n".len()
+ body.len(),
);
for (h, v) in headers {
new_vec.extend_from_slice(h.as_str().as_bytes());
new_vec.extend_from_slice(b": ");
new_vec.extend_from_slice(v);
new_vec.extend_from_slice(b"\r\n");
}
new_vec.extend_from_slice(b"\r\n\r\n");
new_vec.extend_from_slice(body);
post.bytes = new_vec;
Ok((post, ctx))
}
}
/// Assuming there are no more changes to be done on the post, it finalizes
/// which list subscriptions will receive the post in `post.action` field.
pub struct FinalizeRecipients;
impl PostFilter for FinalizeRecipients {
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
trace!("Running FinalizeRecipients filter");
let mut recipients = vec![];
let mut digests = vec![];
let email_from = post.from.get_email();
for subscription in ctx.subscriptions {
trace!("examining subscription {:?}", &subscription);
if subscription.address == email_from {
trace!("subscription is submitter");
}
if subscription.digest {
if subscription.address != email_from || subscription.receive_own_posts {
trace!("Subscription gets digest");
digests.push(subscription.address());
}
continue;
}
if subscription.address != email_from || subscription.receive_own_posts {
trace!("Subscription gets copy");
recipients.push(subscription.address());
}
}
ctx.scheduled_jobs.push(MailJob::Send { recipients });
if !digests.is_empty() {
ctx.scheduled_jobs.push(MailJob::StoreDigest {
recipients: digests,
});
}
post.action = PostAction::Accept;
Ok((post, ctx))
}
}
/// Allow specific MIMEs only.
pub struct MimeReject;
impl PostFilter for MimeReject {
fn feed<'p, 'list>(
self: Box<Self>,
post: &'p mut PostEntry,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
let reject = if let Some(mut settings) = ctx.filter_settings.remove("MimeRejectSettings") {
let map = settings.as_object_mut().unwrap();
let enabled = serde_json::from_value::<bool>(map.remove("enabled").unwrap()).unwrap();
if !enabled {
trace!(
"MimeReject is disabled from settings found for list.pk = {} skipping filter",
ctx.list.pk
);
return Ok((post, ctx));
}
serde_json::from_value::<Vec<String>>(map.remove("reject").unwrap())
} else {
return Ok((post, ctx));
};
trace!("Running MimeReject filter with reject = {:?}", reject);
Ok((post, ctx))
}
}

View File

@ -1,44 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2023 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Named templates, for generated e-mail like confirmations, alerts etc.
//!
//! Template database model: [`Template`](crate::Template).
use std::collections::HashMap;
use serde_json::Value;
use crate::{errors::*, Connection, DbVal};
impl Connection {
/// Get json settings.
pub fn get_settings(&self, list_pk: i64) -> Result<HashMap<String, DbVal<Value>>> {
let mut stmt = self.connection.prepare(
"SELECT pk, name, value FROM list_settings_json WHERE list = ? AND is_valid = 1;",
)?;
let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
let pk: i64 = row.get("pk")?;
let name: String = row.get("name")?;
let value: Value = row.get("value")?;
Ok((name, DbVal(value, pk)))
})?;
Ok(iter.collect::<std::result::Result<HashMap<String, DbVal<Value>>, rusqlite::Error>>()?)
}
}

View File

@ -1,277 +0,0 @@
//(user_version, redo sql, undo sql
&[(1,r##"PRAGMA foreign_keys=ON;
ALTER TABLE templates RENAME TO template;"##,r##"PRAGMA foreign_keys=ON;
ALTER TABLE template RENAME TO templates;"##),(2,r##"PRAGMA foreign_keys=ON;
ALTER TABLE list ADD COLUMN topics JSON NOT NULL CHECK (json_type(topics) == 'array') DEFAULT '[]';"##,r##"PRAGMA foreign_keys=ON;
ALTER TABLE list DROP COLUMN topics;"##),(3,r##"PRAGMA foreign_keys=ON;
UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk;
CREATE TRIGGER
IF NOT EXISTS sort_topics_update_trigger
AFTER UPDATE ON list
FOR EACH ROW
WHEN NEW.topics != OLD.topics
BEGIN
UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS sort_topics_new_trigger
AFTER INSERT ON list
FOR EACH ROW
BEGIN
UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;"##,r##"PRAGMA foreign_keys=ON;
DROP TRIGGER sort_topics_update_trigger;
DROP TRIGGER sort_topics_new_trigger;"##),(4,r##"CREATE TABLE IF NOT EXISTS settings_json_schema (
pk INTEGER PRIMARY KEY NOT NULL,
id TEXT NOT NULL UNIQUE,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS list_settings_json (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
list INTEGER,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
UNIQUE (list, name) ON CONFLICT ROLLBACK
);
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_update
AFTER UPDATE OF value, name, is_valid ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_insert
AFTER INSERT ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS invalidate_settings_json_on_schema_update
AFTER UPDATE OF value, id ON settings_json_schema
FOR EACH ROW
BEGIN
UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
END;
DROP TRIGGER IF EXISTS last_modified_list;
DROP TRIGGER IF EXISTS last_modified_owner;
DROP TRIGGER IF EXISTS last_modified_post_policy;
DROP TRIGGER IF EXISTS last_modified_subscription_policy;
DROP TRIGGER IF EXISTS last_modified_subscription;
DROP TRIGGER IF EXISTS last_modified_account;
DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
DROP TRIGGER IF EXISTS last_modified_template;
DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
DROP TRIGGER IF EXISTS last_modified_list_settings_json;
-- [tag:last_modified_list]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_list
AFTER UPDATE ON list
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE list SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_owner]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_owner
AFTER UPDATE ON owner
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE owner SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_post_policy]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_post_policy
AFTER UPDATE ON post_policy
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE post_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_subscription_policy]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_subscription_policy
AFTER UPDATE ON subscription_policy
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE subscription_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_subscription]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_subscription
AFTER UPDATE ON subscription
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_account]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_account
AFTER UPDATE ON account
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE account SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_candidate_subscription]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_candidate_subscription
AFTER UPDATE ON candidate_subscription
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE candidate_subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_template]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_template
AFTER UPDATE ON template
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE template SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_settings_json_schema]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_settings_json_schema
AFTER UPDATE ON settings_json_schema
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE settings_json_schema SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_list_settings_json]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_list_settings_json
AFTER UPDATE ON list_settings_json
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE list_settings_json SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;"##,r##"DROP TABLE settings_json_schema;
DROP TABLE list_settings_json;"##),(5,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/ArchivedAtLinkSettings",
"$defs": {
"ArchivedAtLinkSettings": {
"title": "ArchivedAtLinkSettings",
"description": "Settings for ArchivedAtLink message filter",
"type": "object",
"properties": {
"template": {
"title": "Jinja template for header value",
"description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
"examples": [
"https://www.example.com/{{msg_id}}",
"https://www.example.com/{{msg_id}}.html"
],
"type": "string",
"pattern": ".+[{][{]msg_id[}][}].*"
},
"preserve_carets": {
"title": "Preserve carets of `Message-ID` in generated value",
"type": "boolean",
"default": false
}
},
"required": [
"template"
]
}
}
}');"##,r##"DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';"##),(6,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/AddSubjectTagPrefixSettings",
"$defs": {
"AddSubjectTagPrefixSettings": {
"title": "AddSubjectTagPrefixSettings",
"description": "Settings for AddSubjectTagPrefix message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, the list subject prefix is added to post subjects.",
"type": "boolean"
}
},
"required": [
"enabled"
]
}
}
}');"##,r##"DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';"##),(7,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/MimeRejectSettings",
"$defs": {
"MimeRejectSettings": {
"title": "MimeRejectSettings",
"description": "Settings for MimeReject message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, list posts that contain mime types in the reject array are rejected.",
"type": "boolean"
},
"reject": {
"title": "Mime types to reject.",
"type": "array",
"minLength": 0,
"items": { "$ref": "#/$defs/MimeType" }
},
"required": [
"enabled"
]
}
},
"MimeType": {
"type": "string",
"maxLength": 127,
"minLength": 3,
"uniqueItems": true,
"pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
}
}
}');"##,r##"DELETE FROM settings_json_schema WHERE id = 'MimeRejectSettings';"##),]

View File

@ -1,746 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Database models: [`MailingList`], [`ListOwner`], [`ListSubscription`],
//! [`PostPolicy`], [`SubscriptionPolicy`] and [`Post`].
use super::*;
pub mod changesets;
use std::borrow::Cow;
use melib::email::Address;
/// A database entry and its primary key. Derefs to its inner type.
///
/// # Example
///
/// ```rust,no_run
/// # use mailpot::{*, models::*};
/// # fn foo(db: &Connection) {
/// let val: Option<DbVal<MailingList>> = db.list(5).unwrap();
/// if let Some(list) = val {
/// assert_eq!(list.pk(), 5);
/// }
/// # }
/// ```
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(transparent)]
pub struct DbVal<T: Send + Sync>(pub T, #[serde(skip)] pub i64);
impl<T: Send + Sync> DbVal<T> {
/// Primary key.
#[inline(always)]
pub fn pk(&self) -> i64 {
self.1
}
/// Unwrap inner value.
#[inline(always)]
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> std::borrow::Borrow<T> for DbVal<T>
where
T: Send + Sync + Sized,
{
fn borrow(&self) -> &T {
&self.0
}
}
impl<T> std::ops::Deref for DbVal<T>
where
T: Send + Sync,
{
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T> std::ops::DerefMut for DbVal<T>
where
T: Send + Sync,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<T> std::fmt::Display for DbVal<T>
where
T: std::fmt::Display + Send + Sync,
{
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.0)
}
}
/// A mailing list entry.
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
pub struct MailingList {
/// Database primary key.
pub pk: i64,
/// Mailing list name.
pub name: String,
/// Mailing list ID (what appears in the subject tag, e.g. `[mailing-list]
/// New post!`).
pub id: String,
/// Mailing list e-mail address.
pub address: String,
/// Discussion topics.
pub topics: Vec<String>,
/// Mailing list description.
pub description: Option<String>,
/// Mailing list archive URL.
pub archive_url: Option<String>,
}
impl std::fmt::Display for MailingList {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
if let Some(description) = self.description.as_ref() {
write!(
fmt,
"[#{} {}] {} <{}>: {}",
self.pk, self.id, self.name, self.address, description
)
} else {
write!(
fmt,
"[#{} {}] {} <{}>",
self.pk, self.id, self.name, self.address
)
}
}
}
impl MailingList {
/// Mailing list display name.
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(
/// &list.display_name(),
/// "\"foobar chat\" <foo-chat@example.com>"
/// );
/// # Ok(())
/// # }
pub fn display_name(&self) -> String {
format!("\"{}\" <{}>", self.name, self.address)
}
#[inline]
/// Request subaddress.
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(&list.request_subaddr(), "foo-chat+request@example.com");
/// # Ok(())
/// # }
pub fn request_subaddr(&self) -> String {
let p = self.address.split('@').collect::<Vec<&str>>();
format!("{}+request@{}", p[0], p[1])
}
/// Value of `List-Id` header.
///
/// See RFC2919 Section 3: <https://www.rfc-editor.org/rfc/rfc2919>
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(
/// &list.id_header(),
/// "Hello world, from foo-chat list <foo-chat.example.com>");
/// # Ok(())
/// # }
pub fn id_header(&self) -> String {
let p = self.address.split('@').collect::<Vec<&str>>();
format!(
"{}{}<{}.{}>",
self.description.as_deref().unwrap_or(""),
self.description.as_ref().map(|_| " ").unwrap_or(""),
self.id,
p[1]
)
}
/// Value of `List-Help` header.
///
/// See RFC2369 Section 3.1: <https://www.rfc-editor.org/rfc/rfc2369#section-3.1>
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(
/// &list.help_header().unwrap(),
/// "<mailto:foo-chat+request@example.com?subject=help>"
/// );
/// # Ok(())
/// # }
pub fn help_header(&self) -> Option<String> {
Some(format!("<mailto:{}?subject=help>", self.request_subaddr()))
}
/// Value of `List-Post` header.
///
/// See RFC2369 Section 3.4: <https://www.rfc-editor.org/rfc/rfc2369#section-3.4>
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(&list.post_header(None).unwrap(), "NO");
/// assert_eq!(
/// &list.post_header(Some(&post_policy)).unwrap(),
/// "<mailto:foo-chat@example.com>"
/// );
/// # Ok(())
/// # }
pub fn post_header(&self, policy: Option<&PostPolicy>) -> Option<String> {
Some(policy.map_or_else(
|| "NO".to_string(),
|p| {
if p.announce_only {
"NO".to_string()
} else {
format!("<mailto:{}>", self.address)
}
},
))
}
/// Value of `List-Unsubscribe` header.
///
/// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(
/// &list.unsubscribe_header(Some(&sub_policy)).unwrap(),
/// "<mailto:foo-chat+request@example.com?subject=unsubscribe>"
/// );
/// # Ok(())
/// # }
pub fn unsubscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
policy.map_or_else(
|| None,
|_| {
Some(format!(
"<mailto:{}?subject=unsubscribe>",
self.request_subaddr()
))
},
)
}
/// Value of `List-Subscribe` header.
///
/// See RFC2369 Section 3.3: <https://www.rfc-editor.org/rfc/rfc2369#section-3.3>
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(
/// &list.subscribe_header(Some(&sub_policy)).unwrap(),
/// "<mailto:foo-chat+request@example.com?subject=subscribe>",
/// );
/// # Ok(())
/// # }
/// ```
pub fn subscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
policy.map_or_else(
|| None,
|_| {
Some(format!(
"<mailto:{}?subject=subscribe>",
self.request_subaddr()
))
},
)
}
/// Value of `List-Archive` header.
///
/// See RFC2369 Section 3.6: <https://www.rfc-editor.org/rfc/rfc2369#section-3.6>
///
/// # Example
///
/// ```rust
/// # fn main() -> mailpot::Result<()> {
#[doc = include_str!("./doctests/db_setup.rs.inc")]
/// assert_eq!(
/// &list.archive_header().unwrap(),
/// "<https://lists.example.com>"
/// );
/// # Ok(())
/// # }
/// ```
pub fn archive_header(&self) -> Option<String> {
self.archive_url.as_ref().map(|url| format!("<{}>", url))
}
/// List address as a [`melib::Address`]
pub fn address(&self) -> Address {
Address::new(Some(self.name.clone()), self.address.clone())
}
/// List unsubscribe action as a [`MailtoAddress`].
pub fn unsubscription_mailto(&self) -> MailtoAddress {
MailtoAddress {
address: self.request_subaddr(),
subject: Some("unsubscribe".to_string()),
}
}
/// List subscribe action as a [`MailtoAddress`].
pub fn subscription_mailto(&self) -> MailtoAddress {
MailtoAddress {
address: self.request_subaddr(),
subject: Some("subscribe".to_string()),
}
}
/// List owner as a [`MailtoAddress`].
pub fn owner_mailto(&self) -> MailtoAddress {
let p = self.address.split('@').collect::<Vec<&str>>();
MailtoAddress {
address: format!("{}+owner@{}", p[0], p[1]),
subject: None,
}
}
/// List archive url value.
pub fn archive_url(&self) -> Option<&str> {
self.archive_url.as_deref()
}
/// Insert all available list headers.
pub fn insert_headers(
&self,
draft: &mut melib::Draft,
post_policy: Option<&PostPolicy>,
subscription_policy: Option<&SubscriptionPolicy>,
) {
for (hdr, val) in [
("List-Id", Some(self.id_header())),
("List-Help", self.help_header()),
("List-Post", self.post_header(post_policy)),
(
"List-Unsubscribe",
self.unsubscribe_header(subscription_policy),
),
("List-Subscribe", self.subscribe_header(subscription_policy)),
("List-Archive", self.archive_header()),
] {
if let Some(val) = val {
draft
.headers
.insert(melib::HeaderName::try_from(hdr).unwrap(), val);
}
}
}
/// Generate help e-mail body containing information on how to subscribe,
/// unsubscribe, post and how to contact the list owners.
pub fn generate_help_email(
&self,
post_policy: Option<&PostPolicy>,
subscription_policy: Option<&SubscriptionPolicy>,
) -> String {
format!(
"Help for {list_name}\n\n{subscribe}\n\n{post}\n\nTo contact the list owners, send an \
e-mail to {contact}\n",
list_name = self.name,
subscribe = subscription_policy.map_or(
Cow::Borrowed("This list is not open to subscriptions."),
|p| if p.open {
Cow::Owned(format!(
"Anyone can subscribe without restrictions. Send an e-mail to {} with the \
subject `subscribe`.",
self.request_subaddr(),
))
} else if p.manual {
Cow::Borrowed(
"The list owners must manually add you to the list of subscriptions.",
)
} else if p.request {
Cow::Owned(format!(
"Anyone can request to subscribe. Send an e-mail to {} with the subject \
`subscribe` and a confirmation will be sent to you when your request is \
approved.",
self.request_subaddr(),
))
} else {
Cow::Borrowed("Please contact the list owners for details on how to subscribe.")
}
),
post = post_policy.map_or(Cow::Borrowed("This list does not allow posting."), |p| {
if p.announce_only {
Cow::Borrowed(
"This list is announce only, which means that you can only receive posts \
from the list owners.",
)
} else if p.subscription_only {
Cow::Owned(format!(
"Only list subscriptions can post to this list. Send your post to {}",
self.address
))
} else if p.approval_needed {
Cow::Owned(format!(
"Anyone can post, but approval from list owners is required if they are \
not subscribed. Send your post to {}",
self.address
))
} else {
Cow::Borrowed("This list does not allow posting.")
}
}),
contact = self.owner_mailto().address,
)
}
/// Utility function to get a `Vec<String>` -which is the expected type of
/// the `topics` field- from a `serde_json::Value`, which is the value
/// stored in the `topics` column in `sqlite3`.
///
/// # Example
///
/// ```rust
/// # use mailpot::models::MailingList;
/// use serde_json::Value;
///
/// # fn main() -> Result<(), serde_json::Error> {
/// let value: Value = serde_json::from_str(r#"["fruits","vegetables"]"#)?;
/// assert_eq!(
/// MailingList::topics_from_json_value(value),
/// Ok(vec!["fruits".to_string(), "vegetables".to_string()])
/// );
///
/// let value: Value = serde_json::from_str(r#"{"invalid":"value"}"#)?;
/// assert!(MailingList::topics_from_json_value(value).is_err());
/// # Ok(())
/// # }
/// ```
pub fn topics_from_json_value(
v: serde_json::Value,
) -> std::result::Result<Vec<String>, rusqlite::Error> {
let err_fn = || {
rusqlite::Error::FromSqlConversionFailure(
8,
rusqlite::types::Type::Text,
anyhow::Error::msg(
"topics column must be a json array of strings serialized as a string, e.g. \
\"[]\" or \"['topicA', 'topicB']\"",
)
.into(),
)
};
v.as_array()
.map(|arr| {
arr.iter()
.map(|v| v.as_str().map(str::to_string))
.collect::<Option<Vec<String>>>()
})
.ok_or_else(err_fn)?
.ok_or_else(err_fn)
}
}
/// A mailing list subscription entry.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ListSubscription {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Subscription's e-mail address.
pub address: String,
/// Subscription's name, optional.
pub name: Option<String>,
/// Subscription's account foreign key, optional.
pub account: Option<i64>,
/// Whether this subscription is enabled.
pub enabled: bool,
/// Whether the e-mail address is verified.
pub verified: bool,
/// Whether subscription wishes to receive list posts as a periodical digest
/// e-mail.
pub digest: bool,
/// Whether subscription wishes their e-mail address hidden from public
/// view.
pub hide_address: bool,
/// Whether subscription wishes to receive mailing list post duplicates,
/// i.e. posts addressed to them and the mailing list to which they are
/// subscribed.
pub receive_duplicates: bool,
/// Whether subscription wishes to receive their own mailing list posts from
/// the mailing list, as a confirmation.
pub receive_own_posts: bool,
/// Whether subscription wishes to receive a plain confirmation for their
/// own mailing list posts.
pub receive_confirmation: bool,
}
impl std::fmt::Display for ListSubscription {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
fmt,
"{} [digest: {}, hide_address: {} verified: {} {}]",
self.address(),
self.digest,
self.hide_address,
self.verified,
if self.enabled {
"enabled"
} else {
"not enabled"
},
)
}
}
impl ListSubscription {
/// Subscription address as a [`melib::Address`]
pub fn address(&self) -> Address {
Address::new(self.name.clone(), self.address.clone())
}
}
/// A mailing list post policy entry.
///
/// Only one of the boolean flags must be set to true.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PostPolicy {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Whether the policy is announce only (Only list owners can submit posts,
/// and everyone will receive them).
pub announce_only: bool,
/// Whether the policy is "subscription only" (Only list subscriptions can
/// post).
pub subscription_only: bool,
/// Whether the policy is "approval needed" (Anyone can post, but approval
/// from list owners is required if they are not subscribed).
pub approval_needed: bool,
/// Whether the policy is "open" (Anyone can post, but approval from list
/// owners is required. Subscriptions are not enabled).
pub open: bool,
/// Custom policy.
pub custom: bool,
}
impl std::fmt::Display for PostPolicy {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
/// A mailing list owner entry.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ListOwner {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Mailing list owner e-mail address.
pub address: String,
/// Mailing list owner name, optional.
pub name: Option<String>,
}
impl std::fmt::Display for ListOwner {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "[#{} {}] {}", self.pk, self.list, self.address())
}
}
impl From<ListOwner> for ListSubscription {
fn from(val: ListOwner) -> Self {
Self {
pk: 0,
list: val.list,
address: val.address,
name: val.name,
account: None,
digest: false,
hide_address: false,
receive_duplicates: true,
receive_own_posts: false,
receive_confirmation: true,
enabled: true,
verified: true,
}
}
}
impl ListOwner {
/// Owner address as a [`melib::Address`]
pub fn address(&self) -> Address {
Address::new(self.name.clone(), self.address.clone())
}
}
/// A mailing list post entry.
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Post {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Envelope `From` of post.
pub envelope_from: Option<String>,
/// `From` header address of post.
pub address: String,
/// `Message-ID` header value of post.
pub message_id: String,
/// Post as bytes.
pub message: Vec<u8>,
/// Unix timestamp of date.
pub timestamp: u64,
/// Date header as string.
pub datetime: String,
/// Month-year as a `YYYY-mm` formatted string, for use in archives.
pub month_year: String,
}
impl std::fmt::Debug for Post {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
fmt.debug_struct(stringify!(Post))
.field("pk", &self.pk)
.field("list", &self.list)
.field("envelope_from", &self.envelope_from)
.field("address", &self.address)
.field("message_id", &self.message_id)
.field("message", &String::from_utf8_lossy(&self.message))
.field("timestamp", &self.timestamp)
.field("datetime", &self.datetime)
.field("month_year", &self.month_year)
.finish()
}
}
impl std::fmt::Display for Post {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
/// A mailing list subscription policy entry.
///
/// Only one of the policy boolean flags must be set to true.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct SubscriptionPolicy {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Send confirmation e-mail when subscription is finalized.
pub send_confirmation: bool,
/// Anyone can subscribe without restrictions.
pub open: bool,
/// Only list owners can manually add subscriptions.
pub manual: bool,
/// Anyone can request to subscribe.
pub request: bool,
/// Allow subscriptions, but handle it manually.
pub custom: bool,
}
impl std::fmt::Display for SubscriptionPolicy {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
/// An account entry.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Account {
/// Database primary key.
pub pk: i64,
/// Accounts's display name, optional.
pub name: Option<String>,
/// Account's e-mail address.
pub address: String,
/// GPG public key.
pub public_key: Option<String>,
/// SSH public key.
pub password: String,
/// Whether this account is enabled.
pub enabled: bool,
}
impl std::fmt::Display for Account {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
/// A mailing list subscription candidate.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ListCandidateSubscription {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Subscription's e-mail address.
pub address: String,
/// Subscription's name, optional.
pub name: Option<String>,
/// Accepted, foreign key on [`ListSubscription`].
pub accepted: Option<i64>,
}
impl ListCandidateSubscription {
/// Subscription request address as a [`melib::Address`]
pub fn address(&self) -> Address {
Address::new(self.name.clone(), self.address.clone())
}
}
impl std::fmt::Display for ListCandidateSubscription {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
fmt,
"List_pk: {} name: {:?} address: {} accepted: {:?}",
self.list, self.name, self.address, self.accepted,
)
}
}

View File

@ -1,120 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Changeset structs: update specific struct fields.
macro_rules! impl_display {
($t:ty) => {
impl std::fmt::Display for $t {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
};
}
/// Changeset struct for [`Mailinglist`](super::MailingList).
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct MailingListChangeset {
/// Database primary key.
pub pk: i64,
/// Optional new value.
pub name: Option<String>,
/// Optional new value.
pub id: Option<String>,
/// Optional new value.
pub address: Option<String>,
/// Optional new value.
pub description: Option<Option<String>>,
/// Optional new value.
pub archive_url: Option<Option<String>>,
/// Optional new value.
pub owner_local_part: Option<Option<String>>,
/// Optional new value.
pub request_local_part: Option<Option<String>>,
/// Optional new value.
pub verify: Option<bool>,
/// Optional new value.
pub hidden: Option<bool>,
/// Optional new value.
pub enabled: Option<bool>,
}
impl_display!(MailingListChangeset);
/// Changeset struct for [`ListSubscription`](super::ListSubscription).
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct ListSubscriptionChangeset {
/// Mailing list foreign key (See [`MailingList`](super::MailingList)).
pub list: i64,
/// Subscription e-mail address.
pub address: String,
/// Optional new value.
pub account: Option<Option<i64>>,
/// Optional new value.
pub name: Option<Option<String>>,
/// Optional new value.
pub digest: Option<bool>,
/// Optional new value.
pub enabled: Option<bool>,
/// Optional new value.
pub verified: Option<bool>,
/// Optional new value.
pub hide_address: Option<bool>,
/// Optional new value.
pub receive_duplicates: Option<bool>,
/// Optional new value.
pub receive_own_posts: Option<bool>,
/// Optional new value.
pub receive_confirmation: Option<bool>,
}
impl_display!(ListSubscriptionChangeset);
/// Changeset struct for [`ListOwner`](super::ListOwner).
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct ListOwnerChangeset {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`](super::MailingList)).
pub list: i64,
/// Optional new value.
pub address: Option<String>,
/// Optional new value.
pub name: Option<Option<String>>,
}
impl_display!(ListOwnerChangeset);
/// Changeset struct for [`Account`](super::Account).
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct AccountChangeset {
/// Account e-mail address.
pub address: String,
/// Optional new value.
pub name: Option<Option<String>>,
/// Optional new value.
pub public_key: Option<Option<String>>,
/// Optional new value.
pub password: Option<String>,
/// Optional new value.
pub enabled: Option<Option<bool>>,
}
impl_display!(AccountChangeset);

View File

@ -1,404 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! How each list handles new posts and new subscriptions.
mod post_policy {
use log::trace;
use rusqlite::OptionalExtension;
use crate::{
errors::{ErrorKind::*, *},
models::{DbVal, PostPolicy},
Connection,
};
impl Connection {
/// Fetch the post policy of a mailing list.
pub fn list_post_policy(&self, pk: i64) -> Result<Option<DbVal<PostPolicy>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM post_policy WHERE list = ?;")?;
let ret = stmt
.query_row([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
PostPolicy {
pk,
list: row.get("list")?,
announce_only: row.get("announce_only")?,
subscription_only: row.get("subscription_only")?,
approval_needed: row.get("approval_needed")?,
open: row.get("open")?,
custom: row.get("custom")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Remove an existing list policy.
///
/// # Examples
///
/// ```
/// # use mailpot::{models::*, Configuration, Connection, SendMail};
/// # use tempfile::TempDir;
/// #
/// # let tmp_dir = TempDir::new().unwrap();
/// # let db_path = tmp_dir.path().join("mpot.db");
/// # let config = Configuration {
/// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
/// # db_path: db_path.clone(),
/// # data_path: tmp_dir.path().to_path_buf(),
/// # administrators: vec![],
/// # };
/// #
/// # fn do_test(config: Configuration) {
/// let db = Connection::open_or_create_db(config).unwrap().trusted();
/// # assert!(db.list_post_policy(1).unwrap().is_none());
/// let list = db
/// .create_list(MailingList {
/// pk: 0,
/// name: "foobar chat".into(),
/// id: "foo-chat".into(),
/// address: "foo-chat@example.com".into(),
/// description: None,
/// topics: vec![],
/// archive_url: None,
/// })
/// .unwrap();
///
/// # assert!(db.list_post_policy(list.pk()).unwrap().is_none());
/// let pol = db
/// .set_list_post_policy(PostPolicy {
/// pk: -1,
/// list: list.pk(),
/// announce_only: false,
/// subscription_only: true,
/// approval_needed: false,
/// open: false,
/// custom: false,
/// })
/// .unwrap();
/// # assert_eq!(db.list_post_policy(list.pk()).unwrap().as_ref(), Some(&pol));
/// db.remove_list_post_policy(list.pk(), pol.pk()).unwrap();
/// # assert!(db.list_post_policy(list.pk()).unwrap().is_none());
/// # }
/// # do_test(config);
/// ```
///
/// ```should_panic
/// # use mailpot::{models::*, Configuration, Connection, SendMail};
/// # use tempfile::TempDir;
/// #
/// # let tmp_dir = TempDir::new().unwrap();
/// # let db_path = tmp_dir.path().join("mpot.db");
/// # let config = Configuration {
/// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
/// # db_path: db_path.clone(),
/// # data_path: tmp_dir.path().to_path_buf(),
/// # administrators: vec![],
/// # };
/// #
/// # fn do_test(config: Configuration) {
/// let db = Connection::open_or_create_db(config).unwrap().trusted();
/// db.remove_list_post_policy(1, 1).unwrap();
/// # }
/// # do_test(config);
/// ```
pub fn remove_list_post_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
let mut stmt = self
.connection
.prepare("DELETE FROM post_policy WHERE pk = ? AND list = ? RETURNING *;")?;
stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("list or list policy not found!"))
} else {
err.into()
}
})?;
trace!("remove_list_post_policy {} {}.", list_pk, policy_pk);
Ok(())
}
/// Set the unique post policy for a list.
pub fn set_list_post_policy(&self, policy: PostPolicy) -> Result<DbVal<PostPolicy>> {
if !(policy.announce_only
|| policy.subscription_only
|| policy.approval_needed
|| policy.open
|| policy.custom)
{
return Err(Error::new_external(
"Cannot add empty policy. Having no policies is probably what you want to do.",
));
}
let list_pk = policy.list;
let mut stmt = self.connection.prepare(
"INSERT OR REPLACE INTO post_policy(list, announce_only, subscription_only, \
approval_needed, open, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;",
)?;
let ret = stmt
.query_row(
rusqlite::params![
&list_pk,
&policy.announce_only,
&policy.subscription_only,
&policy.approval_needed,
&policy.open,
&policy.custom,
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
PostPolicy {
pk,
list: row.get("list")?,
announce_only: row.get("announce_only")?,
subscription_only: row.get("subscription_only")?,
approval_needed: row.get("approval_needed")?,
open: row.get("open")?,
custom: row.get("custom")?,
},
pk,
))
},
)
.map_err(|err| {
if matches!(
err,
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ffi::ErrorCode::ConstraintViolation,
extended_code: 787
},
_
)
) {
Error::from(err)
.chain_err(|| NotFound("Could not find a list with this pk."))
} else {
err.into()
}
})?;
trace!("set_list_post_policy {:?}.", &ret);
Ok(ret)
}
}
}
mod subscription_policy {
use log::trace;
use rusqlite::OptionalExtension;
use crate::{
errors::{ErrorKind::*, *},
models::{DbVal, SubscriptionPolicy},
Connection,
};
impl Connection {
/// Fetch the subscription policy of a mailing list.
pub fn list_subscription_policy(
&self,
pk: i64,
) -> Result<Option<DbVal<SubscriptionPolicy>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription_policy WHERE list = ?;")?;
let ret = stmt
.query_row([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
SubscriptionPolicy {
pk,
list: row.get("list")?,
send_confirmation: row.get("send_confirmation")?,
open: row.get("open")?,
manual: row.get("manual")?,
request: row.get("request")?,
custom: row.get("custom")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Remove an existing subscription policy.
///
/// # Examples
///
/// ```
/// # use mailpot::{models::*, Configuration, Connection, SendMail};
/// # use tempfile::TempDir;
/// #
/// # let tmp_dir = TempDir::new().unwrap();
/// # let db_path = tmp_dir.path().join("mpot.db");
/// # let config = Configuration {
/// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
/// # db_path: db_path.clone(),
/// # data_path: tmp_dir.path().to_path_buf(),
/// # administrators: vec![],
/// # };
/// #
/// # fn do_test(config: Configuration) {
/// let db = Connection::open_or_create_db(config).unwrap().trusted();
/// let list = db
/// .create_list(MailingList {
/// pk: 0,
/// name: "foobar chat".into(),
/// id: "foo-chat".into(),
/// address: "foo-chat@example.com".into(),
/// description: None,
/// topics: vec![],
/// archive_url: None,
/// })
/// .unwrap();
/// # assert!(db.list_subscription_policy(list.pk()).unwrap().is_none());
/// let pol = db
/// .set_list_subscription_policy(SubscriptionPolicy {
/// pk: -1,
/// list: list.pk(),
/// send_confirmation: false,
/// open: true,
/// manual: false,
/// request: false,
/// custom: false,
/// })
/// .unwrap();
/// # assert_eq!(db.list_subscription_policy(list.pk()).unwrap().as_ref(), Some(&pol));
/// db.remove_list_subscription_policy(list.pk(), pol.pk())
/// .unwrap();
/// # assert!(db.list_subscription_policy(list.pk()).unwrap().is_none());
/// # }
/// # do_test(config);
/// ```
///
/// ```should_panic
/// # use mailpot::{models::*, Configuration, Connection, SendMail};
/// # use tempfile::TempDir;
/// #
/// # let tmp_dir = TempDir::new().unwrap();
/// # let db_path = tmp_dir.path().join("mpot.db");
/// # let config = Configuration {
/// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
/// # db_path: db_path.clone(),
/// # data_path: tmp_dir.path().to_path_buf(),
/// # administrators: vec![],
/// # };
/// #
/// # fn do_test(config: Configuration) {
/// let db = Connection::open_or_create_db(config).unwrap().trusted();
/// db.remove_list_post_policy(1, 1).unwrap();
/// # }
/// # do_test(config);
/// ```
pub fn remove_list_subscription_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
let mut stmt = self.connection.prepare(
"DELETE FROM subscription_policy WHERE pk = ? AND list = ? RETURNING *;",
)?;
stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("list or list policy not found!"))
} else {
err.into()
}
})?;
trace!("remove_list_subscription_policy {} {}.", list_pk, policy_pk);
Ok(())
}
/// Set the unique post policy for a list.
pub fn set_list_subscription_policy(
&self,
policy: SubscriptionPolicy,
) -> Result<DbVal<SubscriptionPolicy>> {
if !(policy.open || policy.manual || policy.request || policy.custom) {
return Err(Error::new_external(
"Cannot add empty policy. Having no policy is probably what you want to do.",
));
}
let list_pk = policy.list;
let mut stmt = self.connection.prepare(
"INSERT OR REPLACE INTO subscription_policy(list, send_confirmation, open, \
manual, request, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;",
)?;
let ret = stmt
.query_row(
rusqlite::params![
&list_pk,
&policy.send_confirmation,
&policy.open,
&policy.manual,
&policy.request,
&policy.custom,
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
SubscriptionPolicy {
pk,
list: row.get("list")?,
send_confirmation: row.get("send_confirmation")?,
open: row.get("open")?,
manual: row.get("manual")?,
request: row.get("request")?,
custom: row.get("custom")?,
},
pk,
))
},
)
.map_err(|err| {
if matches!(
err,
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ffi::ErrorCode::ConstraintViolation,
extended_code: 787
},
_
)
) {
Error::from(err)
.chain_err(|| NotFound("Could not find a list with this pk."))
} else {
err.into()
}
})?;
trace!("set_list_subscription_policy {:?}.", &ret);
Ok(ret)
}
}
}

View File

@ -1,678 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Generate configuration for the postfix mail server.
//!
//! ## Transport maps (`transport_maps`)
//!
//! <http://www.postfix.org/postconf.5.html#transport_maps>
//!
//! ## Local recipient maps (`local_recipient_maps`)
//!
//! <http://www.postfix.org/postconf.5.html#local_recipient_maps>
//!
//! ## Relay domains (`relay_domains`)
//!
//! <http://www.postfix.org/postconf.5.html#relay_domains>
use std::{
borrow::Cow,
convert::TryInto,
fs::OpenOptions,
io::{BufWriter, Read, Seek, Write},
path::{Path, PathBuf},
};
use crate::{errors::*, Configuration, Connection, DbVal, MailingList, PostPolicy};
/*
transport_maps =
hash:/path-to-mailman/var/data/postfix_lmtp
local_recipient_maps =
hash:/path-to-mailman/var/data/postfix_lmtp
relay_domains =
hash:/path-to-mailman/var/data/postfix_domains
*/
/// Settings for generating postfix configuration.
///
/// See the struct methods for details.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PostfixConfiguration {
/// The UNIX username under which the mailpot process who processed incoming
/// mail is launched.
pub user: Cow<'static, str>,
/// The UNIX group under which the mailpot process who processed incoming
/// mail is launched.
pub group: Option<Cow<'static, str>>,
/// The absolute path of the `mailpot` binary.
pub binary_path: PathBuf,
/// The maximum number of `mailpot` processes to launch. Default is `1`.
#[serde(default)]
pub process_limit: Option<u64>,
/// The directory in which the map files are saved.
/// Default is `data_path` from [`Configuration`].
#[serde(default)]
pub map_output_path: Option<PathBuf>,
/// The name of the Postfix service name to use.
/// Default is `mailpot`.
///
/// A Postfix service is a daemon managed by the postfix process.
/// Each entry in the `master.cf` configuration file defines a single
/// service.
///
/// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
/// <https://www.postfix.org/master.5.html>.
#[serde(default)]
pub transport_name: Option<Cow<'static, str>>,
}
impl Default for PostfixConfiguration {
fn default() -> Self {
Self {
user: "user".into(),
group: None,
binary_path: Path::new("/usr/bin/mailpot").to_path_buf(),
process_limit: None,
map_output_path: None,
transport_name: None,
}
}
}
impl PostfixConfiguration {
/// Generate service line entry for Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
pub fn generate_master_cf_entry(&self, config: &Configuration, config_path: &Path) -> String {
let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
format!(
"{transport_name} unix - n n - {process_limit} pipe
flags=RX user={username}{group_sep}{groupname} directory={{{data_dir}}} argv={{{binary_path}}} -c \
{{{config_path}}} post",
username = &self.user,
group_sep = if self.group.is_none() { "" } else { ":" },
groupname = self.group.as_deref().unwrap_or_default(),
process_limit = self.process_limit.unwrap_or(1),
binary_path = &self.binary_path.display(),
config_path = &config_path.display(),
data_dir = &config.data_path.display()
)
}
/// Generate `transport_maps` and `local_recipient_maps` for Postfix.
///
/// The output must be saved in a plain text file.
/// To make Postfix be able to read them, the `postmap` application must be
/// executed with the path to the map file as its sole argument.
/// `postmap` is usually distributed along with the other Postfix binaries.
pub fn generate_maps(
&self,
lists: &[(DbVal<MailingList>, Option<DbVal<PostPolicy>>)],
) -> String {
let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
let mut ret = String::new();
ret.push_str("# Automatically generated by mailpot.\n");
ret.push_str(
"# Upon its creation and every time it is modified, postmap(1) must be called for the \
changes to take effect:\n",
);
ret.push_str("# postmap /path/to/map_file\n\n");
// [ref:TODO]: add custom addresses if PostPolicy is custom
let calc_width = |list: &MailingList, policy: Option<&PostPolicy>| -> usize {
let addr = list.address.len();
match policy {
None => 0,
Some(PostPolicy { .. }) => addr + "+request".len(),
}
};
let Some(width): Option<usize> =
lists.iter().map(|(l, p)| calc_width(l, p.as_deref())).max()
else {
return ret;
};
for (list, policy) in lists {
macro_rules! push_addr {
($addr:expr) => {{
let addr = &$addr;
ret.push_str(addr);
for _ in 0..(width - addr.len() + 5) {
ret.push(' ');
}
ret.push_str(transport_name);
ret.push_str(":\n");
}};
}
match policy.as_deref() {
None => log::debug!(
"Not generating postfix map entry for list {} because it has no post_policy \
set.",
list.id
),
Some(PostPolicy { open: true, .. }) => {
push_addr!(list.address);
ret.push('\n');
}
Some(PostPolicy { .. }) => {
push_addr!(list.address);
push_addr!(list.subscription_mailto().address);
push_addr!(list.owner_mailto().address);
ret.push('\n');
}
}
}
// pop second of the last two newlines
ret.pop();
ret
}
/// Save service to Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
///
/// If you wish to do it manually, get the text output from
/// [`PostfixConfiguration::generate_master_cf_entry`] and manually append it to the [`master.cf`](https://www.postfix.org/master.5.html) file.
///
/// If `master_cf_path` is `None`, the location of the file is assumed to be
/// `/etc/postfix/master.cf`.
pub fn save_master_cf_entry(
&self,
config: &Configuration,
config_path: &Path,
master_cf_path: Option<&Path>,
) -> Result<()> {
let new_entry = self.generate_master_cf_entry(config, config_path);
let path = master_cf_path.unwrap_or_else(|| Path::new("/etc/postfix/master.cf"));
// Create backup file.
let path_bkp = path.with_extension("cf.bkp");
std::fs::copy(path, &path_bkp).context(format!(
"Could not create master.cf backup {}",
path_bkp.display()
))?;
log::info!(
"Created backup of {} to {}.",
path.display(),
path_bkp.display()
);
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(false)
.open(path)
.context(format!("Could not open {}", path.display()))?;
let mut previous_content = String::new();
file.rewind()
.context(format!("Could not access {}", path.display()))?;
file.read_to_string(&mut previous_content)
.context(format!("Could not access {}", path.display()))?;
let original_size = previous_content.len();
let lines = previous_content.lines().collect::<Vec<&str>>();
let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
if let Some(line) = lines.iter().find(|l| l.starts_with(transport_name)) {
let pos = previous_content.find(line).ok_or_else(|| {
Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
})?;
let end_needle = " argv=";
let end_pos = previous_content[pos..]
.find(end_needle)
.and_then(|pos2| {
previous_content[(pos + pos2 + end_needle.len())..]
.find('\n')
.map(|p| p + pos + pos2 + end_needle.len())
})
.ok_or_else(|| {
Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
})?;
previous_content.replace_range(pos..end_pos, &new_entry);
} else {
previous_content.push_str(&new_entry);
previous_content.push('\n');
}
file.rewind()?;
if previous_content.len() < original_size {
file.set_len(
previous_content
.len()
.try_into()
.expect("Could not convert usize file size to u64"),
)?;
}
let mut file = BufWriter::new(file);
file.write_all(previous_content.as_bytes())
.context(format!("Could not access {}", path.display()))?;
file.flush()
.context(format!("Could not access {}", path.display()))?;
log::debug!("Saved new master.cf to {}.", path.display(),);
Ok(())
}
/// Generate `transport_maps` and `local_recipient_maps` for Postfix.
///
/// To succeed the user the command is running under must have write and
/// read access to `postfix_data_directory` and the `postmap` binary
/// must be discoverable in your `PATH` environment variable.
///
/// `postmap` is usually distributed along with the other Postfix binaries.
pub fn save_maps(&self, config: &Configuration) -> Result<()> {
let db = Connection::open_db(config.clone())?;
let Some(postmap) = find_binary_in_path("postmap") else {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
"Could not find postmap binary in PATH.",
))));
};
let lists = db.lists()?;
let lists_post_policies = lists
.into_iter()
.map(|l| {
let pk = l.pk;
Ok((l, db.list_post_policy(pk)?))
})
.collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
let content = self.generate_maps(&lists_post_policies);
let path = self
.map_output_path
.as_deref()
.unwrap_or(&config.data_path)
.join("mailpot_postfix_map");
let mut file = BufWriter::new(
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)
.context(format!("Could not open {}", path.display()))?,
);
file.write_all(content.as_bytes())
.context(format!("Could not write to {}", path.display()))?;
file.flush()
.context(format!("Could not write to {}", path.display()))?;
let output = std::process::Command::new("sh")
.arg("-c")
.arg(&format!("{} {}", postmap.display(), path.display()))
.output()
.with_context(|| {
format!(
"Could not execute `postmap` binary in path {}",
postmap.display()
)
})?;
if !output.status.success() {
use std::os::unix::process::ExitStatusExt;
if let Some(code) = output.status.code() {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
format!(
"{} exited with {}.\nstderr was:\n---{}---\nstdout was\n---{}---\n",
code,
postmap.display(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
),
))));
} else if let Some(signum) = output.status.signal() {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
format!(
"{} was killed with signal {}.\nstderr was:\n---{}---\nstdout \
was\n---{}---\n",
signum,
postmap.display(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
),
))));
} else {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
format!(
"{} failed for unknown reason.\nstderr was:\n---{}---\nstdout \
was\n---{}---\n",
postmap.display(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
),
))));
}
}
Ok(())
}
}
fn find_binary_in_path(binary_name: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths).find_map(|dir| {
let full_path = dir.join(binary_name);
if full_path.is_file() {
Some(full_path)
} else {
None
}
})
})
}
#[test]
fn test_postfix_generation() -> Result<()> {
use tempfile::TempDir;
use crate::*;
mailpot_tests::init_stderr_logging();
fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
use melib::smtp::*;
SmtpServerConf {
hostname: "127.0.0.1".into(),
port: 1025,
envelope_from: "foo-chat@example.com".into(),
auth: SmtpAuth::None,
security: SmtpSecurity::None,
extensions: Default::default(),
}
}
let tmp_dir = TempDir::new()?;
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::Smtp(get_smtp_conf()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let config_path = tmp_dir.path().join("conf.toml");
{
let mut conf = OpenOptions::new()
.write(true)
.create(true)
.open(&config_path)?;
conf.write_all(config.to_toml().as_bytes())?;
conf.flush()?;
}
let db = Connection::open_or_create_db(config)?.trusted();
assert!(db.lists()?.is_empty());
// Create three lists:
//
// - One without any policy, which should not show up in postfix maps.
// - One with subscriptions disabled, which would only add the list address in
// postfix maps.
// - One with subscriptions enabled, which should add all addresses (list,
// list+{un,}subscribe, etc).
let first = db.create_list(MailingList {
pk: 0,
name: "first".into(),
id: "first".into(),
address: "first@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?;
assert_eq!(first.pk(), 1);
let second = db.create_list(MailingList {
pk: 0,
name: "second".into(),
id: "second".into(),
address: "second@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?;
assert_eq!(second.pk(), 2);
let post_policy = db.set_list_post_policy(PostPolicy {
pk: 0,
list: second.pk(),
announce_only: false,
subscription_only: false,
approval_needed: false,
open: true,
custom: false,
})?;
assert_eq!(post_policy.pk(), 1);
let third = db.create_list(MailingList {
pk: 0,
name: "third".into(),
id: "third".into(),
address: "third@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?;
assert_eq!(third.pk(), 3);
let post_policy = db.set_list_post_policy(PostPolicy {
pk: 0,
list: third.pk(),
announce_only: false,
subscription_only: false,
approval_needed: true,
open: false,
custom: false,
})?;
assert_eq!(post_policy.pk(), 2);
let mut postfix_conf = PostfixConfiguration::default();
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user={} directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
&postfix_conf.user,
tmp_dir.path().display(),
config_path.display()
);
assert_eq!(
expected_mastercf_entry.trim_end(),
postfix_conf.generate_master_cf_entry(db.conf(), &config_path)
);
let lists = db.lists()?;
let lists_post_policies = lists
.into_iter()
.map(|l| {
let pk = l.pk;
Ok((l, db.list_post_policy(pk)?))
})
.collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
let maps = postfix_conf.generate_maps(&lists_post_policies);
let expected = "second@example.com mailpot:
third@example.com mailpot:
third+request@example.com mailpot:
third+owner@example.com mailpot:
";
assert!(
maps.ends_with(expected),
"maps has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
maps,
expected
);
let master_edit_value = r#"#
# Postfix master process configuration file. For details on the format
# of the file, see the master(5) manual page (command: "man 5 master" or
# on-line: http://www.postfix.org/master.5.html).
#
# Do not forget to execute "postfix reload" after editing this file.
#
# ==========================================================================
# service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (no) (never) (100)
# ==========================================================================
smtp inet n - y - - smtpd
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
qmgr unix n - n 300 1 qmgr
#qmgr unix n - n 300 1 oqmgr
tlsmgr unix - - y 1000? 1 tlsmgr
rewrite unix - - y - - trivial-rewrite
bounce unix - - y - 0 bounce
defer unix - - y - 0 bounce
trace unix - - y - 0 bounce
verify unix - - y - 1 verify
flush unix n - y 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
smtp unix - - y - - smtp
relay unix - - y - - smtp
-o syslog_name=postfix/$service_name
showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd
maildrop unix - n n - - pipe
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
uucp unix - n n - - pipe
flags=Fqhu user=uucp argv=uux -r -n -z -a$sender - $nexthop!rmail ($recipient)
#
# Other external delivery methods.
#
ifmail unix - n n - - pipe
flags=F user=ftn argv=/usr/lib/ifmail/ifmail -r $nexthop ($recipient)
bsmtp unix - n n - - pipe
flags=Fq. user=bsmtp argv=/usr/lib/bsmtp/bsmtp -t$nexthop -f$sender $recipient
scalemail-backend unix - n n - 2 pipe
flags=R user=scalemail argv=/usr/lib/scalemail/bin/scalemail-store ${nexthop} ${user} ${extension}
mailman unix - n n - - pipe
flags=FRX user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py ${nexthop} ${user}
"#;
let path = tmp_dir.path().join("master.cf");
{
let mut mastercf = OpenOptions::new().write(true).create(true).open(&path)?;
mastercf.write_all(master_edit_value.as_bytes())?;
mastercf.flush()?;
}
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut first = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut first)?;
}
assert!(
first.ends_with(&expected_mastercf_entry),
"edited master.cf has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
first,
expected_mastercf_entry
);
// test that a smaller entry can be successfully replaced
postfix_conf.user = "nobody".into();
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut second = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut second)?;
}
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user=nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
tmp_dir.path().display(),
config_path.display()
);
assert!(
second.ends_with(&expected_mastercf_entry),
"doubly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
with\n{:?}",
second,
expected_mastercf_entry
);
// test that a larger entry can be successfully replaced
postfix_conf.user = "hackerman".into();
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut third = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut third)?;
}
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user=hackerman directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
tmp_dir.path().display(),
config_path.display(),
);
assert!(
third.ends_with(&expected_mastercf_entry),
"triply edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
with\n{:?}",
third,
expected_mastercf_entry
);
// test that if groupname is given it is rendered correctly.
postfix_conf.group = Some("nobody".into());
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut fourth = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut fourth)?;
}
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user=hackerman:nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
tmp_dir.path().display(),
config_path.display(),
);
assert!(
fourth.ends_with(&expected_mastercf_entry),
"fourthly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
with\n{:?}",
fourth,
expected_mastercf_entry
);
Ok(())
}

View File

@ -1,801 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Processing new posts.
use std::borrow::Cow;
use log::{info, trace};
use melib::Envelope;
use rusqlite::OptionalExtension;
use crate::{
errors::*,
mail::{ListContext, ListRequest, PostAction, PostEntry},
models::{changesets::AccountChangeset, Account, DbVal, ListSubscription, MailingList, Post},
queue::{Queue, QueueEntry},
templates::Template,
Connection,
};
impl Connection {
/// Insert a mailing list post into the database.
pub fn insert_post(&self, list_pk: i64, message: &[u8], env: &Envelope) -> Result<i64> {
let from_ = env.from();
let address = if from_.is_empty() {
String::new()
} else {
from_[0].get_email()
};
let datetime: std::borrow::Cow<'_, str> = if !env.date.is_empty() {
env.date.as_str().into()
} else {
melib::utils::datetime::timestamp_to_string(
env.timestamp,
Some(melib::utils::datetime::formats::RFC822_DATE),
true,
)
.into()
};
let message_id = env.message_id_display();
let mut stmt = self.connection.prepare(
"INSERT OR REPLACE INTO post(list, address, message_id, message, datetime, timestamp) \
VALUES(?, ?, ?, ?, ?, ?) RETURNING pk;",
)?;
let pk = stmt.query_row(
rusqlite::params![
&list_pk,
&address,
&message_id,
&message,
&datetime,
&env.timestamp
],
|row| {
let pk: i64 = row.get("pk")?;
Ok(pk)
},
)?;
trace!(
"insert_post list_pk {}, from {:?} message_id {:?} post_pk {}.",
list_pk,
address,
message_id,
pk
);
Ok(pk)
}
/// Process a new mailing list post.
///
/// In case multiple processes can access the database at any time, use an
/// `EXCLUSIVE` transaction before calling this function.
/// See [`Connection::transaction`].
pub fn post(&self, env: &Envelope, raw: &[u8], _dry_run: bool) -> Result<()> {
let result = self.inner_post(env, raw, _dry_run);
if let Err(err) = result {
return match self.insert_to_queue(QueueEntry::new(
Queue::Error,
None,
Some(Cow::Borrowed(env)),
raw,
Some(err.to_string()),
)?) {
Ok(idx) => {
log::info!(
"Inserted mail from {:?} into error_queue at index {}",
env.from(),
idx
);
Err(err)
}
Err(err2) => {
log::error!(
"Could not insert mail from {:?} into error_queue: {err2}",
env.from(),
);
Err(err.chain_err(|| err2))
}
};
}
result
}
fn inner_post(&self, env: &Envelope, raw: &[u8], _dry_run: bool) -> Result<()> {
trace!("Received envelope to post: {:#?}", &env);
let tos = env.to().to_vec();
if tos.is_empty() {
return Err("Envelope To: field is empty!".into());
}
if env.from().is_empty() {
return Err("Envelope From: field is empty!".into());
}
let mut lists = self.lists()?;
let prev_list_len = lists.len();
for t in &tos {
if let Some((addr, subaddr)) = t.subaddress("+") {
lists.retain(|list| {
if !addr.contains_address(&list.address()) {
return true;
}
if let Err(err) = ListRequest::try_from((subaddr.as_str(), env))
.and_then(|req| self.request(list, req, env, raw))
{
info!("Processing request returned error: {}", err);
}
false
});
if lists.len() != prev_list_len {
// Was request, handled above.
return Ok(());
}
}
}
lists.retain(|list| {
trace!(
"Is post related to list {}? {}",
&list,
tos.iter().any(|a| a.contains_address(&list.address()))
);
tos.iter().any(|a| a.contains_address(&list.address()))
});
if lists.is_empty() {
return Err(format!(
"No relevant mailing list found for these addresses: {:?}",
tos
)
.into());
}
trace!("Configuration is {:#?}", &self.conf);
for mut list in lists {
trace!("Examining list {}", list.display_name());
let filters = self.list_filters(&list);
let subscriptions = self.list_subscriptions(list.pk)?;
let owners = self.list_owners(list.pk)?;
trace!("List subscriptions {:#?}", &subscriptions);
let mut list_ctx = ListContext {
post_policy: self.list_post_policy(list.pk)?,
subscription_policy: self.list_subscription_policy(list.pk)?,
list_owners: &owners,
subscriptions: &subscriptions,
scheduled_jobs: vec![],
filter_settings: self.get_settings(list.pk)?,
list: &mut list,
};
let mut post = PostEntry {
message_id: env.message_id().clone(),
from: env.from()[0].clone(),
bytes: raw.to_vec(),
to: env.to().to_vec(),
action: PostAction::Hold,
};
let result = filters
.into_iter()
.try_fold((&mut post, &mut list_ctx), |(p, c), f| f.feed(p, c));
trace!("result {:#?}", result);
let PostEntry { bytes, action, .. } = post;
trace!("Action is {:#?}", action);
let post_env = melib::Envelope::from_bytes(&bytes, None)?;
match action {
PostAction::Accept => {
let _post_pk = self.insert_post(list_ctx.list.pk, &bytes, &post_env)?;
trace!("post_pk is {:#?}", _post_pk);
for job in list_ctx.scheduled_jobs.iter() {
trace!("job is {:#?}", &job);
if let crate::mail::MailJob::Send { recipients } = job {
trace!("recipients: {:?}", &recipients);
if recipients.is_empty() {
trace!("list has no recipients");
}
for recipient in recipients {
let mut env = post_env.clone();
env.set_to(melib::smallvec::smallvec![recipient.clone()]);
self.insert_to_queue(QueueEntry::new(
Queue::Out,
Some(list.pk),
Some(Cow::Owned(env)),
&bytes,
None,
)?)?;
}
}
}
}
PostAction::Reject { reason } => {
log::info!("PostAction::Reject {{ reason: {} }}", reason);
for f in env.from() {
/* send error notice to e-mail sender */
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::GENERIC_FAILURE,
default_fn: Some(Template::default_generic_failure),
list: &list,
context: minijinja::context! {
list => &list,
subject => format!("Your post to {} was rejected.", list.id),
details => &reason,
},
queue: Queue::Out,
comment: format!("PostAction::Reject {{ reason: {} }}", reason)
.into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
}
/* error handled by notifying submitter */
return Ok(());
}
PostAction::Defer { reason } => {
trace!("PostAction::Defer {{ reason: {} }}", reason);
for f in env.from() {
/* send error notice to e-mail sender */
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::GENERIC_FAILURE,
default_fn: Some(Template::default_generic_failure),
list: &list,
context: minijinja::context! {
list => &list,
subject => format!("Your post to {} was deferred.", list.id),
details => &reason,
},
queue: Queue::Out,
comment: format!("PostAction::Defer {{ reason: {} }}", reason)
.into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
}
self.insert_to_queue(QueueEntry::new(
Queue::Deferred,
Some(list.pk),
Some(Cow::Borrowed(&post_env)),
&bytes,
Some(format!("PostAction::Defer {{ reason: {} }}", reason)),
)?)?;
return Ok(());
}
PostAction::Hold => {
trace!("PostAction::Hold");
self.insert_to_queue(QueueEntry::new(
Queue::Hold,
Some(list.pk),
Some(Cow::Borrowed(&post_env)),
&bytes,
Some("PostAction::Hold".to_string()),
)?)?;
return Ok(());
}
}
}
Ok(())
}
/// Process a new mailing list request.
pub fn request(
&self,
list: &DbVal<MailingList>,
request: ListRequest,
env: &Envelope,
raw: &[u8],
) -> Result<()> {
match request {
ListRequest::Help => {
trace!(
"help action for addresses {:?} in list {}",
env.from(),
list
);
let subscription_policy = self.list_subscription_policy(list.pk)?;
let post_policy = self.list_post_policy(list.pk)?;
let subject = format!("Help for {}", list.name);
let details = list
.generate_help_email(post_policy.as_deref(), subscription_policy.as_deref());
for f in env.from() {
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::GENERIC_HELP,
default_fn: Some(Template::default_generic_help),
list,
context: minijinja::context! {
list => &list,
subject => &subject,
details => &details,
},
queue: Queue::Out,
comment: "Help request".into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
}
}
ListRequest::Subscribe => {
trace!(
"subscribe action for addresses {:?} in list {}",
env.from(),
list
);
let subscription_policy = self.list_subscription_policy(list.pk)?;
let approval_needed = subscription_policy
.as_ref()
.map(|p| !p.open)
.unwrap_or(false);
for f in env.from() {
let email_from = f.get_email();
if self
.list_subscription_by_address(list.pk, &email_from)
.is_ok()
{
/* send error notice to e-mail sender */
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::GENERIC_FAILURE,
default_fn: Some(Template::default_generic_failure),
list,
context: minijinja::context! {
list => &list,
subject => format!("You are already subscribed to {}.", list.id),
details => "No action has been taken since you are already subscribed to the list.",
},
queue: Queue::Out,
comment: format!("Address {} is already subscribed to list {}", f, list.id).into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
continue;
}
let subscription = ListSubscription {
pk: 0,
list: list.pk,
address: f.get_email(),
account: None,
name: f.get_display_name(),
digest: false,
hide_address: false,
receive_duplicates: true,
receive_own_posts: false,
receive_confirmation: true,
enabled: !approval_needed,
verified: true,
};
if approval_needed {
match self.add_candidate_subscription(list.pk, subscription) {
Ok(v) => {
let list_owners = self.list_owners(list.pk)?;
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::SUBSCRIPTION_REQUEST_NOTICE_OWNER,
default_fn: Some(
Template::default_subscription_request_owner,
),
list,
context: minijinja::context! {
list => &list,
candidate => &v,
},
queue: Queue::Out,
comment: Template::SUBSCRIPTION_REQUEST_NOTICE_OWNER.into(),
},
list_owners.iter().map(|owner| Cow::Owned(owner.address())),
)?;
}
Err(err) => {
log::error!(
"Could not create candidate subscription for {f:?}: {err}"
);
/* send error notice to e-mail sender */
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::GENERIC_FAILURE,
default_fn: Some(Template::default_generic_failure),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: format!(
"Could not create candidate subscription for {f:?}: \
{err}"
)
.into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
/* send error details to list owners */
let list_owners = self.list_owners(list.pk)?;
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::ADMIN_NOTICE,
default_fn: Some(Template::default_admin_notice),
list,
context: minijinja::context! {
list => &list,
details => err.to_string(),
},
queue: Queue::Out,
comment: format!(
"Could not create candidate subscription for {f:?}: \
{err}"
)
.into(),
},
list_owners.iter().map(|owner| Cow::Owned(owner.address())),
)?;
}
}
} else if let Err(err) = self.add_subscription(list.pk, subscription) {
log::error!("Could not create subscription for {f:?}: {err}");
/* send error notice to e-mail sender */
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::GENERIC_FAILURE,
default_fn: Some(Template::default_generic_failure),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: format!("Could not create subscription for {f:?}: {err}")
.into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
/* send error details to list owners */
let list_owners = self.list_owners(list.pk)?;
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::ADMIN_NOTICE,
default_fn: Some(Template::default_admin_notice),
list,
context: minijinja::context! {
list => &list,
details => err.to_string(),
},
queue: Queue::Out,
comment: format!("Could not create subscription for {f:?}: {err}")
.into(),
},
list_owners.iter().map(|owner| Cow::Owned(owner.address())),
)?;
} else {
self.send_subscription_confirmation(list, f)?;
}
}
}
ListRequest::Unsubscribe => {
trace!(
"unsubscribe action for addresses {:?} in list {}",
env.from(),
list
);
for f in env.from() {
if let Err(err) = self.remove_subscription(list.pk, &f.get_email()) {
log::error!("Could not unsubscribe {f:?}: {err}");
/* send error notice to e-mail sender */
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::GENERIC_FAILURE,
default_fn: Some(Template::default_generic_failure),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: format!("Could not unsubscribe {f:?}: {err}").into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
/* send error details to list owners */
let list_owners = self.list_owners(list.pk)?;
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::ADMIN_NOTICE,
default_fn: Some(Template::default_admin_notice),
list,
context: minijinja::context! {
list => &list,
details => err.to_string(),
},
queue: Queue::Out,
comment: format!("Could not unsubscribe {f:?}: {err}").into(),
},
list_owners.iter().map(|owner| Cow::Owned(owner.address())),
)?;
} else {
self.send_unsubscription_confirmation(list, f)?;
}
}
}
ListRequest::Other(ref req) if req == "owner" => {
trace!(
"list-owner mail action for addresses {:?} in list {}",
env.from(),
list
);
return Err("list-owner emails are not implemented yet.".into());
//FIXME: mail to list-owner
/*
for _owner in self.list_owners(list.pk)? {
self.insert_to_queue(
Queue::Out,
Some(list.pk),
None,
draft.finalise()?.as_bytes(),
"list-owner-forward".to_string(),
)?;
}
*/
}
ListRequest::Other(ref req) if req.trim().eq_ignore_ascii_case("password") => {
trace!(
"list-request password set action for addresses {:?} in list {list}",
env.from(),
);
let body = env.body_bytes(raw);
let password = body.text();
// TODO: validate SSH public key with `ssh-keygen`.
for f in env.from() {
let email_from = f.get_email();
if let Ok(sub) = self.list_subscription_by_address(list.pk, &email_from) {
match self.account_by_address(&email_from)? {
Some(_acc) => {
let changeset = AccountChangeset {
address: email_from.clone(),
name: None,
public_key: None,
password: Some(password.clone()),
enabled: None,
};
self.update_account(changeset)?;
}
None => {
// Create new account.
self.add_account(Account {
pk: 0,
name: sub.name.clone(),
address: sub.address.clone(),
public_key: None,
password: password.clone(),
enabled: sub.enabled,
})?;
}
}
}
}
}
ListRequest::RetrieveMessages(ref message_ids) => {
trace!(
"retrieve messages {message_ids:?} action for addresses {:?} in list {list}",
env.from(),
);
return Err("message retrievals are not implemented yet.".into());
}
ListRequest::RetrieveArchive(ref from, ref to) => {
trace!(
"retrieve archive action from {from:?} to {to:?} for addresses {:?} in list \
{list}",
env.from(),
);
return Err("message retrievals are not implemented yet.".into());
}
ListRequest::ChangeSetting(ref setting, ref toggle) => {
trace!(
"change setting {setting}, request with value {toggle:?} for addresses {:?} \
in list {list}",
env.from(),
);
return Err("setting digest options via e-mail is not implemented yet.".into());
}
ListRequest::Other(ref req) => {
trace!(
"unknown request action {req} for addresses {:?} in list {list}",
env.from(),
);
return Err(format!("Unknown request {req}.").into());
}
}
Ok(())
}
/// Fetch all year and month values for which at least one post exists in
/// `yyyy-mm` format.
pub fn months(&self, list_pk: i64) -> Result<Vec<String>> {
let mut stmt = self.connection.prepare(
"SELECT DISTINCT strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') FROM post \
WHERE list = ?;",
)?;
let months_iter = stmt.query_map([list_pk], |row| {
let val: String = row.get(0)?;
Ok(val)
})?;
let mut ret = vec![];
for month in months_iter {
let month = month?;
ret.push(month);
}
Ok(ret)
}
/// Find a post by its `Message-ID` email header.
pub fn list_post_by_message_id(
&self,
list_pk: i64,
message_id: &str,
) -> Result<Option<DbVal<Post>>> {
let mut stmt = self.connection.prepare(
"SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
FROM post WHERE list = ?1 AND (message_id = ?2 OR concat('<', ?2, '>') = message_id);",
)?;
let ret = stmt
.query_row(rusqlite::params![&list_pk, &message_id], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Post {
pk,
list: row.get("list")?,
envelope_from: row.get("envelope_from")?,
address: row.get("address")?,
message_id: row.get("message_id")?,
message: row.get("message")?,
timestamp: row.get("timestamp")?,
datetime: row.get("datetime")?,
month_year: row.get("month_year")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Helper function to send a template reply.
pub fn send_reply_with_list_template<'ctx, F: Fn() -> Template>(
&self,
render_context: TemplateRenderContext<'ctx, F>,
recipients: impl Iterator<Item = Cow<'ctx, melib::Address>>,
) -> Result<()> {
let TemplateRenderContext {
template,
default_fn,
list,
context,
queue,
comment,
} = render_context;
let post_policy = self.list_post_policy(list.pk)?;
let subscription_policy = self.list_subscription_policy(list.pk)?;
let templ = self
.fetch_template(template, Some(list.pk))?
.map(DbVal::into_inner)
.or_else(|| default_fn.map(|f| f()))
.ok_or_else(|| -> crate::Error {
format!("Template with name {template:?} was not found.").into()
})?;
let mut draft = templ.render(context)?;
draft
.headers
.insert(melib::HeaderName::FROM, list.request_subaddr());
for addr in recipients {
let mut draft = draft.clone();
draft
.headers
.insert(melib::HeaderName::TO, addr.to_string());
list.insert_headers(
&mut draft,
post_policy.as_deref(),
subscription_policy.as_deref(),
);
self.insert_to_queue(QueueEntry::new(
queue,
Some(list.pk),
None,
draft.finalise()?.as_bytes(),
Some(comment.to_string()),
)?)?;
}
Ok(())
}
/// Send subscription confirmation.
pub fn send_subscription_confirmation(
&self,
list: &DbVal<MailingList>,
address: &melib::Address,
) -> Result<()> {
log::trace!(
"Added subscription to list {list:?} for address {address:?}, sending confirmation."
);
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::SUBSCRIPTION_CONFIRMATION,
default_fn: Some(Template::default_subscription_confirmation),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: Template::SUBSCRIPTION_CONFIRMATION.into(),
},
std::iter::once(Cow::Borrowed(address)),
)
}
/// Send unsubscription confirmation.
pub fn send_unsubscription_confirmation(
&self,
list: &DbVal<MailingList>,
address: &melib::Address,
) -> Result<()> {
log::trace!(
"Removed subscription to list {list:?} for address {address:?}, sending confirmation."
);
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::UNSUBSCRIPTION_CONFIRMATION,
default_fn: Some(Template::default_unsubscription_confirmation),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: Template::UNSUBSCRIPTION_CONFIRMATION.into(),
},
std::iter::once(Cow::Borrowed(address)),
)
}
}
/// Helper type for [`Connection::send_reply_with_list_template`].
#[derive(Debug)]
pub struct TemplateRenderContext<'ctx, F: Fn() -> Template> {
/// Template name.
pub template: &'ctx str,
/// If template is not found, call a function that returns one.
pub default_fn: Option<F>,
/// The pertinent list.
pub list: &'ctx DbVal<MailingList>,
/// [`minijinja`]'s template context.
pub context: minijinja::value::Value,
/// Destination queue in the database.
pub queue: Queue,
/// Comment for the queue entry in the database.
pub comment: Cow<'static, str>,
}

View File

@ -1,370 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! # Queues
use std::borrow::Cow;
use melib::Envelope;
use crate::{errors::*, models::DbVal, Connection, DateTime};
/// In-database queues of mail.
#[derive(Copy, Clone, Eq, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Queue {
/// Messages that have been received but not yet processed, await
/// processing in the `maildrop` queue. Messages can be added to the
/// `maildrop` queue even when mailpot is not running.
Maildrop,
/// List administrators may introduce rules for emails to be placed
/// indefinitely in the `hold` queue. Messages placed in the `hold`
/// queue stay there until the administrator intervenes. No periodic
/// delivery attempts are made for messages in the `hold` queue.
Hold,
/// When all the deliverable recipients for a message are delivered, and for
/// some recipients delivery failed for a transient reason (it might
/// succeed later), the message is placed in the `deferred` queue.
Deferred,
/// Invalid received or generated e-mail saved for debug and troubleshooting
/// reasons.
Corrupt,
/// Emails that must be sent as soon as possible.
Out,
/// Error queue
Error,
}
impl std::str::FromStr for Queue {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Ok(match s.trim() {
s if s.eq_ignore_ascii_case(stringify!(Maildrop)) => Self::Maildrop,
s if s.eq_ignore_ascii_case(stringify!(Hold)) => Self::Hold,
s if s.eq_ignore_ascii_case(stringify!(Deferred)) => Self::Deferred,
s if s.eq_ignore_ascii_case(stringify!(Corrupt)) => Self::Corrupt,
s if s.eq_ignore_ascii_case(stringify!(Out)) => Self::Out,
s if s.eq_ignore_ascii_case(stringify!(Error)) => Self::Error,
other => return Err(Error::new_external(format!("Invalid Queue name: {other}."))),
})
}
}
impl Queue {
/// Returns the name of the queue used in the database schema.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Maildrop => "maildrop",
Self::Hold => "hold",
Self::Deferred => "deferred",
Self::Corrupt => "corrupt",
Self::Out => "out",
Self::Error => "error",
}
}
/// Returns all possible variants as `&'static str`
pub const fn possible_values() -> &'static [&'static str] {
const VALUES: &[&str] = &[
Queue::Maildrop.as_str(),
Queue::Hold.as_str(),
Queue::Deferred.as_str(),
Queue::Corrupt.as_str(),
Queue::Out.as_str(),
Queue::Error.as_str(),
];
VALUES
}
}
impl std::fmt::Display for Queue {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.as_str())
}
}
/// A queue entry.
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct QueueEntry {
/// Database primary key.
pub pk: i64,
/// Owner queue.
pub queue: Queue,
/// Related list foreign key, optional.
pub list: Option<i64>,
/// Entry comment, optional.
pub comment: Option<String>,
/// Entry recipients in rfc5322 format.
pub to_addresses: String,
/// Entry submitter in rfc5322 format.
pub from_address: String,
/// Entry subject.
pub subject: String,
/// Entry Message-ID in rfc5322 format.
pub message_id: String,
/// Message in rfc5322 format as bytes.
pub message: Vec<u8>,
/// Unix timestamp of date.
pub timestamp: u64,
/// Datetime as string.
pub datetime: DateTime,
}
impl std::fmt::Display for QueueEntry {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl std::fmt::Debug for QueueEntry {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
fmt.debug_struct(stringify!(QueueEntry))
.field("pk", &self.pk)
.field("queue", &self.queue)
.field("list", &self.list)
.field("comment", &self.comment)
.field("to_addresses", &self.to_addresses)
.field("from_address", &self.from_address)
.field("subject", &self.subject)
.field("message_id", &self.message_id)
.field("message length", &self.message.len())
.field(
"message",
&format!("{:.15}", String::from_utf8_lossy(&self.message)),
)
.field("timestamp", &self.timestamp)
.field("datetime", &self.datetime)
.finish()
}
}
impl QueueEntry {
/// Create new entry.
pub fn new(
queue: Queue,
list: Option<i64>,
env: Option<Cow<'_, Envelope>>,
raw: &[u8],
comment: Option<String>,
) -> Result<Self> {
let env = env
.map(Ok)
.unwrap_or_else(|| melib::Envelope::from_bytes(raw, None).map(Cow::Owned))?;
let now = chrono::offset::Utc::now();
Ok(Self {
pk: -1,
list,
queue,
comment,
to_addresses: env.field_to_to_string(),
from_address: env.field_from_to_string(),
subject: env.subject().to_string(),
message_id: env.message_id().to_string(),
message: raw.to_vec(),
timestamp: now.timestamp() as u64,
datetime: now,
})
}
}
impl Connection {
/// Insert a received email into a queue.
pub fn insert_to_queue(&self, mut entry: QueueEntry) -> Result<DbVal<QueueEntry>> {
log::trace!("Inserting to queue: {entry}");
let mut stmt = self.connection.prepare(
"INSERT INTO queue(which, list, comment, to_addresses, from_address, subject, \
message_id, message, timestamp, datetime) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
RETURNING pk;",
)?;
let pk = stmt.query_row(
rusqlite::params![
entry.queue.as_str(),
&entry.list,
&entry.comment,
&entry.to_addresses,
&entry.from_address,
&entry.subject,
&entry.message_id,
&entry.message,
&entry.timestamp,
&entry.datetime,
],
|row| {
let pk: i64 = row.get("pk")?;
Ok(pk)
},
)?;
entry.pk = pk;
Ok(DbVal(entry, pk))
}
/// Fetch all queue entries.
pub fn queue(&self, queue: Queue) -> Result<Vec<DbVal<QueueEntry>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM queue WHERE which = ?;")?;
let iter = stmt.query_map([&queue.as_str()], |row| {
let pk = row.get::<_, i64>("pk")?;
Ok(DbVal(
QueueEntry {
pk,
queue,
list: row.get::<_, Option<i64>>("list")?,
comment: row.get::<_, Option<String>>("comment")?,
to_addresses: row.get::<_, String>("to_addresses")?,
from_address: row.get::<_, String>("from_address")?,
subject: row.get::<_, String>("subject")?,
message_id: row.get::<_, String>("message_id")?,
message: row.get::<_, Vec<u8>>("message")?,
timestamp: row.get::<_, u64>("timestamp")?,
datetime: row.get::<_, DateTime>("datetime")?,
},
pk,
))
})?;
let mut ret = vec![];
for item in iter {
let item = item?;
ret.push(item);
}
Ok(ret)
}
/// Delete queue entries returning the deleted values.
pub fn delete_from_queue(&self, queue: Queue, index: Vec<i64>) -> Result<Vec<QueueEntry>> {
let tx = self.savepoint(Some(stringify!(delete_from_queue)))?;
let cl = |row: &rusqlite::Row<'_>| {
Ok(QueueEntry {
pk: -1,
queue,
list: row.get::<_, Option<i64>>("list")?,
comment: row.get::<_, Option<String>>("comment")?,
to_addresses: row.get::<_, String>("to_addresses")?,
from_address: row.get::<_, String>("from_address")?,
subject: row.get::<_, String>("subject")?,
message_id: row.get::<_, String>("message_id")?,
message: row.get::<_, Vec<u8>>("message")?,
timestamp: row.get::<_, u64>("timestamp")?,
datetime: row.get::<_, DateTime>("datetime")?,
})
};
let mut stmt = if index.is_empty() {
tx.connection
.prepare("DELETE FROM queue WHERE which = ? RETURNING *;")?
} else {
tx.connection
.prepare("DELETE FROM queue WHERE which = ? AND pk IN rarray(?) RETURNING *;")?
};
let iter = if index.is_empty() {
stmt.query_map([&queue.as_str()], cl)?
} else {
// Note: A `Rc<Vec<Value>>` must be used as the parameter.
let index = std::rc::Rc::new(
index
.into_iter()
.map(rusqlite::types::Value::from)
.collect::<Vec<rusqlite::types::Value>>(),
);
stmt.query_map(rusqlite::params![queue.as_str(), index], cl)?
};
let mut ret = vec![];
for item in iter {
let item = item?;
ret.push(item);
}
drop(stmt);
tx.commit()?;
Ok(ret)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::*;
#[test]
fn test_queue_delete_array() {
use tempfile::TempDir;
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
for i in 0..5 {
db.insert_to_queue(
QueueEntry::new(
Queue::Hold,
None,
None,
format!("Subject: testing\r\nMessage-Id: {i}@localhost\r\n\r\nHello\r\n")
.as_bytes(),
None,
)
.unwrap(),
)
.unwrap();
}
let entries = db.queue(Queue::Hold).unwrap();
assert_eq!(entries.len(), 5);
let out_entries = db.delete_from_queue(Queue::Out, vec![]).unwrap();
assert_eq!(db.queue(Queue::Hold).unwrap().len(), 5);
assert!(out_entries.is_empty());
let deleted_entries = db.delete_from_queue(Queue::Hold, vec![]).unwrap();
assert_eq!(deleted_entries.len(), 5);
assert_eq!(
&entries
.iter()
.cloned()
.map(DbVal::into_inner)
.map(|mut e| {
e.pk = -1;
e
})
.collect::<Vec<_>>(),
&deleted_entries
);
for e in deleted_entries {
db.insert_to_queue(e).unwrap();
}
let index = db
.queue(Queue::Hold)
.unwrap()
.into_iter()
.skip(2)
.map(|e| e.pk())
.take(2)
.collect::<Vec<i64>>();
let deleted_entries = db.delete_from_queue(Queue::Hold, index).unwrap();
assert_eq!(deleted_entries.len(), 2);
assert_eq!(db.queue(Queue::Hold).unwrap().len(), 3);
}
}

View File

@ -1,657 +0,0 @@
PRAGMA foreign_keys = true;
PRAGMA encoding = 'UTF-8';
CREATE TABLE IF NOT EXISTS list (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
id TEXT NOT NULL UNIQUE,
address TEXT NOT NULL UNIQUE,
owner_local_part TEXT,
request_local_part TEXT,
archive_url TEXT,
description TEXT,
topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
verify BOOLEAN CHECK (verify IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
hidden BOOLEAN CHECK (hidden IN (0, 1)) NOT NULL DEFAULT 0,
enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS owner (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS post_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
announce_only BOOLEAN CHECK (announce_only IN (0, 1)) NOT NULL
DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
subscription_only BOOLEAN CHECK (subscription_only IN (0, 1)) NOT NULL
DEFAULT 0,
approval_needed BOOLEAN CHECK (approval_needed IN (0, 1)) NOT NULL
DEFAULT 0,
open BOOLEAN CHECK (open IN (0, 1)) NOT NULL DEFAULT 0,
custom BOOLEAN CHECK (custom IN (0, 1)) NOT NULL DEFAULT 0,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
CHECK((
(custom) OR ((
(open) OR ((
(approval_needed) OR ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
)
AND NOT
(
(approval_needed) AND ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
))
)
AND NOT
(
(open) AND ((
(approval_needed) OR ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
)
AND NOT
(
(approval_needed) AND ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
))
))
)
AND NOT
(
(custom) AND ((
(open) OR ((
(approval_needed) OR ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
)
AND NOT
(
(approval_needed) AND ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
))
)
AND NOT
(
(open) AND ((
(approval_needed) OR ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
)
AND NOT
(
(approval_needed) AND ((
(announce_only) OR (subscription_only)
)
AND NOT
(
(announce_only) AND (subscription_only)
))
))
))
)),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS subscription_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
send_confirmation BOOLEAN CHECK (send_confirmation IN (0, 1)) NOT NULL
DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
open BOOLEAN CHECK (open IN (0, 1)) NOT NULL DEFAULT 0,
manual BOOLEAN CHECK (manual IN (0, 1)) NOT NULL DEFAULT 0,
request BOOLEAN CHECK (request IN (0, 1)) NOT NULL DEFAULT 0,
custom BOOLEAN CHECK (custom IN (0, 1)) NOT NULL DEFAULT 0,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
CHECK((
(open) OR ((
(manual) OR ((
(request) OR (custom)
)
AND NOT
(
(request) AND (custom)
))
)
AND NOT
(
(manual) AND ((
(request) OR (custom)
)
AND NOT
(
(request) AND (custom)
))
))
)
AND NOT
(
(open) AND ((
(manual) OR ((
(request) OR (custom)
)
AND NOT
(
(request) AND (custom)
))
)
AND NOT
(
(manual) AND ((
(request) OR (custom)
)
AND NOT
(
(request) AND (custom)
))
))
)),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
account INTEGER,
enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL
DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
verified BOOLEAN CHECK (verified IN (0, 1)) NOT NULL
DEFAULT 1,
digest BOOLEAN CHECK (digest IN (0, 1)) NOT NULL
DEFAULT 0,
hide_address BOOLEAN CHECK (hide_address IN (0, 1)) NOT NULL
DEFAULT 0,
receive_duplicates BOOLEAN CHECK (receive_duplicates IN (0, 1)) NOT NULL
DEFAULT 1,
receive_own_posts BOOLEAN CHECK (receive_own_posts IN (0, 1)) NOT NULL
DEFAULT 0,
receive_confirmation BOOLEAN CHECK (receive_confirmation IN (0, 1)) NOT NULL
DEFAULT 1,
last_digest INTEGER NOT NULL DEFAULT (unixepoch()),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (account) REFERENCES account(pk) ON DELETE SET NULL,
UNIQUE (list, address) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS account (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT,
address TEXT NOT NULL UNIQUE,
public_key TEXT,
password TEXT NOT NULL,
enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS candidate_subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
accepted INTEGER UNIQUE,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
UNIQUE (list, address) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS post (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
envelope_from TEXT,
address TEXT NOT NULL,
message_id TEXT NOT NULL,
message BLOB NOT NULL,
headers_json TEXT,
timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
datetime TEXT NOT NULL DEFAULT (datetime()),
created INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS template (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
list INTEGER,
subject TEXT,
headers_json TEXT,
body TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
UNIQUE (list, name) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS settings_json_schema (
pk INTEGER PRIMARY KEY NOT NULL,
id TEXT NOT NULL UNIQUE,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS list_settings_json (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
list INTEGER,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
UNIQUE (list, name) ON CONFLICT ROLLBACK
);
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_update
AFTER UPDATE OF value, name, is_valid ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_insert
AFTER INSERT ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS invalidate_settings_json_on_schema_update
AFTER UPDATE OF value, id ON settings_json_schema
FOR EACH ROW
BEGIN
UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
END;
-- # Queues
--
-- ## The "maildrop" queue
--
-- Messages that have been submitted but not yet processed, await processing
-- in the "maildrop" queue. Messages can be added to the "maildrop" queue
-- even when mailpot is not running.
--
-- ## The "deferred" queue
--
-- When all the deliverable recipients for a message are delivered, and for
-- some recipients delivery failed for a transient reason (it might succeed
-- later), the message is placed in the "deferred" queue.
--
-- ## The "hold" queue
--
-- List administrators may introduce rules for emails to be placed
-- indefinitely in the "hold" queue. Messages placed in the "hold" queue stay
-- there until the administrator intervenes. No periodic delivery attempts
-- are made for messages in the "hold" queue.
-- ## The "out" queue
--
-- Emails that must be sent as soon as possible.
CREATE TABLE IF NOT EXISTS queue (
pk INTEGER PRIMARY KEY NOT NULL,
which TEXT
CHECK (
which IN
('maildrop',
'hold',
'deferred',
'corrupt',
'error',
'out')
) NOT NULL,
list INTEGER,
comment TEXT,
to_addresses TEXT NOT NULL,
from_address TEXT NOT NULL,
subject TEXT NOT NULL,
message_id TEXT NOT NULL,
message BLOB NOT NULL,
timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
datetime TEXT NOT NULL DEFAULT (datetime()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
UNIQUE (to_addresses, message_id) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS bounce (
pk INTEGER PRIMARY KEY NOT NULL,
subscription INTEGER NOT NULL UNIQUE,
count INTEGER NOT NULL DEFAULT 0,
last_bounce TEXT NOT NULL DEFAULT (datetime()),
FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
CREATE INDEX IF NOT EXISTS list_idx ON list(id);
CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
-- [tag:accept_candidate]: Update candidacy with 'subscription' foreign key on
-- 'subscription' insert.
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
END;
-- [tag:verify_subscription_email]: If list settings require e-mail to be
-- verified, update new subscription's 'verify' column value.
CREATE TRIGGER IF NOT EXISTS verify_subscription_email AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE subscription
SET verified = 0, last_modified = unixepoch()
WHERE
subscription.pk = NEW.pk
AND
EXISTS
(SELECT 1 FROM list WHERE pk = NEW.list AND verify = 1);
END;
-- [tag:add_account]: Update list subscription entries with 'account' foreign
-- key, if addresses match.
CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
FOR EACH ROW
BEGIN
UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
WHERE subscription.address = NEW.address;
END;
-- [tag:add_account_to_subscription]: When adding a new 'subscription', auto
-- set 'account' value if there already exists an 'account' entry with the
-- same address.
CREATE TRIGGER IF NOT EXISTS add_account_to_subscription
AFTER INSERT ON subscription
FOR EACH ROW
WHEN
NEW.account IS NULL
AND EXISTS (SELECT 1 FROM account WHERE address = NEW.address)
BEGIN
UPDATE subscription
SET account = (SELECT pk FROM account WHERE address = NEW.address),
last_modified = unixepoch()
WHERE subscription.pk = NEW.pk;
END;
-- [tag:last_modified_list]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_list
AFTER UPDATE ON list
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE list SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_owner]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_owner
AFTER UPDATE ON owner
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE owner SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_post_policy]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_post_policy
AFTER UPDATE ON post_policy
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE post_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_subscription_policy]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_subscription_policy
AFTER UPDATE ON subscription_policy
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE subscription_policy SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_subscription]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_subscription
AFTER UPDATE ON subscription
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_account]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_account
AFTER UPDATE ON account
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE account SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_candidate_subscription]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_candidate_subscription
AFTER UPDATE ON candidate_subscription
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE candidate_subscription SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_template]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_template
AFTER UPDATE ON template
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE template SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_settings_json_schema]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_settings_json_schema
AFTER UPDATE ON settings_json_schema
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE settings_json_schema SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
-- [tag:last_modified_list_settings_json]: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_list_settings_json
AFTER UPDATE ON list_settings_json
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE list_settings_json SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS sort_topics_update_trigger
AFTER UPDATE ON list
FOR EACH ROW
WHEN NEW.topics != OLD.topics
BEGIN
UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS sort_topics_new_trigger
AFTER INSERT ON list
FOR EACH ROW
BEGIN
UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;
-- 005.data.sql
INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/ArchivedAtLinkSettings",
"$defs": {
"ArchivedAtLinkSettings": {
"title": "ArchivedAtLinkSettings",
"description": "Settings for ArchivedAtLink message filter",
"type": "object",
"properties": {
"template": {
"title": "Jinja template for header value",
"description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
"examples": [
"https://www.example.com/{{msg_id}}",
"https://www.example.com/{{msg_id}}.html"
],
"type": "string",
"pattern": ".+[{][{]msg_id[}][}].*"
},
"preserve_carets": {
"title": "Preserve carets of `Message-ID` in generated value",
"type": "boolean",
"default": false
}
},
"required": [
"template"
]
}
}
}');
-- 006.data.sql
INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/AddSubjectTagPrefixSettings",
"$defs": {
"AddSubjectTagPrefixSettings": {
"title": "AddSubjectTagPrefixSettings",
"description": "Settings for AddSubjectTagPrefix message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, the list subject prefix is added to post subjects.",
"type": "boolean"
}
},
"required": [
"enabled"
]
}
}
}');
-- 007.data.sql
INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/$defs/MimeRejectSettings",
"$defs": {
"MimeRejectSettings": {
"title": "MimeRejectSettings",
"description": "Settings for MimeReject message filter",
"type": "object",
"properties": {
"enabled": {
"title": "If true, list posts that contain mime types in the reject array are rejected.",
"type": "boolean"
},
"reject": {
"title": "Mime types to reject.",
"type": "array",
"minLength": 0,
"items": { "$ref": "#/$defs/MimeType" }
},
"required": [
"enabled"
]
}
},
"MimeType": {
"type": "string",
"maxLength": 127,
"minLength": 3,
"uniqueItems": true,
"pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
}
}
}');
-- Set current schema version.
PRAGMA user_version = 7;

View File

@ -1,359 +0,0 @@
define(xor, `dnl
(
($1) OR ($2)
)
AND NOT
(
($1) AND ($2)
)')dnl
dnl
dnl # Define boolean column types and defaults
define(BOOLEAN_TYPE, `BOOLEAN CHECK ($1 IN (0, 1)) NOT NULL')dnl
define(BOOLEAN_FALSE, `0')dnl
define(BOOLEAN_TRUE, `1')dnl
define(BOOLEAN_DOCS, ` -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1')dnl
dnl
dnl # defile comment functions
dnl
dnl # Write the string '['+'tag'+':'+... with a macro so that tagref check
dnl # doesn't pick up on it as a duplicate.
define(__TAG, `tag')dnl
define(TAG, `['__TAG()`:$1]')dnl
dnl
dnl # define triggers
define(update_last_modified, `
-- 'TAG(last_modified_$1)`: update last_modified on every change.
CREATE TRIGGER
IF NOT EXISTS last_modified_$1
AFTER UPDATE ON $1
FOR EACH ROW
WHEN NEW.last_modified == OLD.last_modified
BEGIN
UPDATE $1 SET last_modified = unixepoch()
WHERE pk = NEW.pk;
END;')dnl
dnl
PRAGMA foreign_keys = true;
PRAGMA encoding = 'UTF-8';
CREATE TABLE IF NOT EXISTS list (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
id TEXT NOT NULL UNIQUE,
address TEXT NOT NULL UNIQUE,
owner_local_part TEXT,
request_local_part TEXT,
archive_url TEXT,
description TEXT,
topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
verify BOOLEAN_TYPE(verify) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
hidden BOOLEAN_TYPE(hidden) DEFAULT BOOLEAN_FALSE(),
enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE()
);
CREATE TABLE IF NOT EXISTS owner (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS post_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
announce_only BOOLEAN_TYPE(announce_only)
DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
subscription_only BOOLEAN_TYPE(subscription_only)
DEFAULT BOOLEAN_FALSE(),
approval_needed BOOLEAN_TYPE(approval_needed)
DEFAULT BOOLEAN_FALSE(),
open BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
custom BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
CHECK(xor(custom, xor(open, xor(approval_needed, xor(announce_only, subscription_only))))),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS subscription_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
send_confirmation BOOLEAN_TYPE(send_confirmation)
DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
open BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
manual BOOLEAN_TYPE(manual) DEFAULT BOOLEAN_FALSE(),
request BOOLEAN_TYPE(request) DEFAULT BOOLEAN_FALSE(),
custom BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
CHECK(xor(open, xor(manual, xor(request, custom)))),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
account INTEGER,
enabled BOOLEAN_TYPE(enabled)
DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
verified BOOLEAN_TYPE(verified)
DEFAULT BOOLEAN_TRUE(),
digest BOOLEAN_TYPE(digest)
DEFAULT BOOLEAN_FALSE(),
hide_address BOOLEAN_TYPE(hide_address)
DEFAULT BOOLEAN_FALSE(),
receive_duplicates BOOLEAN_TYPE(receive_duplicates)
DEFAULT BOOLEAN_TRUE(),
receive_own_posts BOOLEAN_TYPE(receive_own_posts)
DEFAULT BOOLEAN_FALSE(),
receive_confirmation BOOLEAN_TYPE(receive_confirmation)
DEFAULT BOOLEAN_TRUE(),
last_digest INTEGER NOT NULL DEFAULT (unixepoch()),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (account) REFERENCES account(pk) ON DELETE SET NULL,
UNIQUE (list, address) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS account (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT,
address TEXT NOT NULL UNIQUE,
public_key TEXT,
password TEXT NOT NULL,
enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS candidate_subscription (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
accepted INTEGER UNIQUE,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
UNIQUE (list, address) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS post (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
envelope_from TEXT,
address TEXT NOT NULL,
message_id TEXT NOT NULL,
message BLOB NOT NULL,
headers_json TEXT,
timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
datetime TEXT NOT NULL DEFAULT (datetime()),
created INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS template (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
list INTEGER,
subject TEXT,
headers_json TEXT,
body TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
UNIQUE (list, name) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS settings_json_schema (
pk INTEGER PRIMARY KEY NOT NULL,
id TEXT NOT NULL UNIQUE,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS list_settings_json (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
list INTEGER,
value JSON NOT NULL CHECK (json_type(value) = 'object'),
is_valid BOOLEAN_TYPE(is_valid) DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
UNIQUE (list, name) ON CONFLICT ROLLBACK
);
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_update
AFTER UPDATE OF value, name, is_valid ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS is_valid_settings_json_on_insert
AFTER INSERT ON list_settings_json
FOR EACH ROW
BEGIN
SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS invalidate_settings_json_on_schema_update
AFTER UPDATE OF value, id ON settings_json_schema
FOR EACH ROW
BEGIN
UPDATE list_settings_json SET name = NEW.id, is_valid = BOOLEAN_FALSE() WHERE name = OLD.id;
END;
-- # Queues
--
-- ## The "maildrop" queue
--
-- Messages that have been submitted but not yet processed, await processing
-- in the "maildrop" queue. Messages can be added to the "maildrop" queue
-- even when mailpot is not running.
--
-- ## The "deferred" queue
--
-- When all the deliverable recipients for a message are delivered, and for
-- some recipients delivery failed for a transient reason (it might succeed
-- later), the message is placed in the "deferred" queue.
--
-- ## The "hold" queue
--
-- List administrators may introduce rules for emails to be placed
-- indefinitely in the "hold" queue. Messages placed in the "hold" queue stay
-- there until the administrator intervenes. No periodic delivery attempts
-- are made for messages in the "hold" queue.
-- ## The "out" queue
--
-- Emails that must be sent as soon as possible.
CREATE TABLE IF NOT EXISTS queue (
pk INTEGER PRIMARY KEY NOT NULL,
which TEXT
CHECK (
which IN
('maildrop',
'hold',
'deferred',
'corrupt',
'error',
'out')
) NOT NULL,
list INTEGER,
comment TEXT,
to_addresses TEXT NOT NULL,
from_address TEXT NOT NULL,
subject TEXT NOT NULL,
message_id TEXT NOT NULL,
message BLOB NOT NULL,
timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
datetime TEXT NOT NULL DEFAULT (datetime()),
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
UNIQUE (to_addresses, message_id) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS bounce (
pk INTEGER PRIMARY KEY NOT NULL,
subscription INTEGER NOT NULL UNIQUE,
count INTEGER NOT NULL DEFAULT 0,
last_bounce TEXT NOT NULL DEFAULT (datetime()),
FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
CREATE INDEX IF NOT EXISTS list_idx ON list(id);
CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
-- TAG(accept_candidate): Update candidacy with 'subscription' foreign key on
-- 'subscription' insert.
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
END;
-- TAG(verify_subscription_email): If list settings require e-mail to be
-- verified, update new subscription's 'verify' column value.
CREATE TRIGGER IF NOT EXISTS verify_subscription_email AFTER INSERT ON subscription
FOR EACH ROW
BEGIN
UPDATE subscription
SET verified = BOOLEAN_FALSE(), last_modified = unixepoch()
WHERE
subscription.pk = NEW.pk
AND
EXISTS
(SELECT 1 FROM list WHERE pk = NEW.list AND verify = BOOLEAN_TRUE());
END;
-- TAG(add_account): Update list subscription entries with 'account' foreign
-- key, if addresses match.
CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
FOR EACH ROW
BEGIN
UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
WHERE subscription.address = NEW.address;
END;
-- TAG(add_account_to_subscription): When adding a new 'subscription', auto
-- set 'account' value if there already exists an 'account' entry with the
-- same address.
CREATE TRIGGER IF NOT EXISTS add_account_to_subscription
AFTER INSERT ON subscription
FOR EACH ROW
WHEN
NEW.account IS NULL
AND EXISTS (SELECT 1 FROM account WHERE address = NEW.address)
BEGIN
UPDATE subscription
SET account = (SELECT pk FROM account WHERE address = NEW.address),
last_modified = unixepoch()
WHERE subscription.pk = NEW.pk;
END;
update_last_modified(`list')
update_last_modified(`owner')
update_last_modified(`post_policy')
update_last_modified(`subscription_policy')
update_last_modified(`subscription')
update_last_modified(`account')
update_last_modified(`candidate_subscription')
update_last_modified(`template')
update_last_modified(`settings_json_schema')
update_last_modified(`list_settings_json')
CREATE TRIGGER
IF NOT EXISTS sort_topics_update_trigger
AFTER UPDATE ON list
FOR EACH ROW
WHEN NEW.topics != OLD.topics
BEGIN
UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;
CREATE TRIGGER
IF NOT EXISTS sort_topics_new_trigger
AFTER INSERT ON list
FOR EACH ROW
BEGIN
UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
END;

View File

@ -1,73 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Submit e-mail through SMTP.
use std::{future::Future, pin::Pin};
use melib::smtp::*;
use crate::{errors::*, queue::QueueEntry, Connection};
type ResultFuture<T> = Result<Pin<Box<dyn Future<Output = Result<T>> + Send + 'static>>>;
impl Connection {
/// Return an SMTP connection handle if the database connection has one
/// configured.
pub fn new_smtp_connection(&self) -> ResultFuture<SmtpConnection> {
if let crate::SendMail::Smtp(ref smtp_conf) = &self.conf().send_mail {
let smtp_conf = smtp_conf.clone();
Ok(Box::pin(async move {
Ok(SmtpConnection::new_connection(smtp_conf).await?)
}))
} else {
Err("No SMTP configuration found: use the shell command instead.".into())
}
}
/// Submit queue items from `values` to their recipients.
pub async fn submit(
smtp_connection: &mut melib::smtp::SmtpConnection,
message: &QueueEntry,
dry_run: bool,
) -> Result<()> {
let QueueEntry {
ref comment,
ref to_addresses,
ref from_address,
ref subject,
ref message,
..
} = message;
log::info!(
"Sending message from {from_address} to {to_addresses} with subject {subject:?} and \
comment {comment:?}",
);
let recipients = melib::Address::list_try_from(to_addresses)
.context(format!("Could not parse {to_addresses:?}"))?;
if dry_run {
log::warn!("Dry run is true, not actually submitting anything to SMTP server.");
} else {
smtp_connection
.mail_transaction(&String::from_utf8_lossy(message), Some(&recipients))
.await?;
}
Ok(())
}
}

View File

@ -1,815 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! User subscriptions.
use log::trace;
use rusqlite::OptionalExtension;
use crate::{
errors::{ErrorKind::*, *},
models::{
changesets::{AccountChangeset, ListSubscriptionChangeset},
Account, ListCandidateSubscription, ListSubscription,
},
Connection, DbVal,
};
impl Connection {
/// Fetch all subscriptions of a mailing list.
pub fn list_subscriptions(&self, list_pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE list = ?;")?;
let list_iter = stmt.query_map([&list_pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Fetch mailing list subscription.
pub fn list_subscription(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListSubscription>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE list = ? AND pk = ?;")?;
let ret = stmt.query_row([&list_pk, &pk], |row| {
let _pk: i64 = row.get("pk")?;
debug_assert_eq!(pk, _pk);
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
Ok(ret)
}
/// Fetch mailing list subscription by their address.
pub fn list_subscription_by_address(
&self,
list_pk: i64,
address: &str,
) -> Result<DbVal<ListSubscription>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE list = ? AND address = ?;")?;
let ret = stmt.query_row(rusqlite::params![&list_pk, &address], |row| {
let pk = row.get("pk")?;
let address_ = row.get("address")?;
debug_assert_eq!(address, &address_);
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: address_,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
Ok(ret)
}
/// Add subscription to mailing list.
pub fn add_subscription(
&self,
list_pk: i64,
mut new_val: ListSubscription,
) -> Result<DbVal<ListSubscription>> {
new_val.list = list_pk;
let mut stmt = self
.connection
.prepare(
"INSERT INTO subscription(list, address, account, name, enabled, digest, \
verified, hide_address, receive_duplicates, receive_own_posts, \
receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;",
)
.unwrap();
let val = stmt.query_row(
rusqlite::params![
&new_val.list,
&new_val.address,
&new_val.account,
&new_val.name,
&new_val.enabled,
&new_val.digest,
&new_val.verified,
&new_val.hide_address,
&new_val.receive_duplicates,
&new_val.receive_own_posts,
&new_val.receive_confirmation
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
account: row.get("account")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
},
)?;
trace!("add_subscription {:?}.", &val);
// table entry might be modified by triggers, so don't rely on RETURNING value.
self.list_subscription(list_pk, val.pk())
}
/// Fetch all candidate subscriptions of a mailing list.
pub fn list_subscription_requests(
&self,
list_pk: i64,
) -> Result<Vec<DbVal<ListCandidateSubscription>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM candidate_subscription WHERE list = ?;")?;
let list_iter = stmt.query_map([&list_pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListCandidateSubscription {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
accepted: row.get("accepted")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Create subscription candidate.
pub fn add_candidate_subscription(
&self,
list_pk: i64,
mut new_val: ListSubscription,
) -> Result<DbVal<ListCandidateSubscription>> {
new_val.list = list_pk;
let mut stmt = self.connection.prepare(
"INSERT INTO candidate_subscription(list, address, name, accepted) VALUES(?, ?, ?, ?) \
RETURNING *;",
)?;
let val = stmt.query_row(
rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListCandidateSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
accepted: row.get("accepted")?,
},
pk,
))
},
)?;
drop(stmt);
trace!("add_candidate_subscription {:?}.", &val);
// table entry might be modified by triggers, so don't rely on RETURNING value.
self.candidate_subscription(val.pk())
}
/// Fetch subscription candidate by primary key.
pub fn candidate_subscription(&self, pk: i64) -> Result<DbVal<ListCandidateSubscription>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM candidate_subscription WHERE pk = ?;")?;
let val = stmt
.query_row(rusqlite::params![&pk], |row| {
let _pk: i64 = row.get("pk")?;
debug_assert_eq!(pk, _pk);
Ok(DbVal(
ListCandidateSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
accepted: row.get("accepted")?,
},
pk,
))
})
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err)
.chain_err(|| NotFound("Candidate subscription with this pk not found!"))
} else {
err.into()
}
})?;
Ok(val)
}
/// Accept subscription candidate.
pub fn accept_candidate_subscription(&self, pk: i64) -> Result<DbVal<ListSubscription>> {
let val = self.connection.query_row(
"INSERT INTO subscription(list, address, name, enabled, digest, verified, \
hide_address, receive_duplicates, receive_own_posts, receive_confirmation) SELECT \
list, address, name, 1, 0, 0, 0, 1, 1, 0 FROM candidate_subscription WHERE pk = ? \
RETURNING *;",
rusqlite::params![&pk],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
},
)?;
trace!("accept_candidate_subscription {:?}.", &val);
// table entry might be modified by triggers, so don't rely on RETURNING value.
let ret = self.list_subscription(val.list, val.pk())?;
// assert that [ref:accept_candidate] trigger works.
debug_assert_eq!(Some(ret.pk), self.candidate_subscription(pk)?.accepted);
Ok(ret)
}
/// Remove a subscription by their address.
pub fn remove_subscription(&self, list_pk: i64, address: &str) -> Result<()> {
self.connection
.query_row(
"DELETE FROM subscription WHERE list = ? AND address = ? RETURNING *;",
rusqlite::params![&list_pk, &address],
|_| Ok(()),
)
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
} else {
err.into()
}
})?;
Ok(())
}
/// Update a mailing list subscription.
pub fn update_subscription(&self, change_set: ListSubscriptionChangeset) -> Result<()> {
let pk = self
.list_subscription_by_address(change_set.list, &change_set.address)?
.pk;
if matches!(
change_set,
ListSubscriptionChangeset {
list: _,
address: _,
account: None,
name: None,
digest: None,
verified: None,
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
enabled: None,
}
) {
return Ok(());
}
let ListSubscriptionChangeset {
list,
address: _,
name,
account,
digest,
enabled,
verified,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
} = change_set;
let tx = self.savepoint(Some(stringify!(update_subscription)))?;
macro_rules! update {
($field:tt) => {{
if let Some($field) = $field {
tx.connection.execute(
concat!(
"UPDATE subscription SET ",
stringify!($field),
" = ? WHERE list = ? AND pk = ?;"
),
rusqlite::params![&$field, &list, &pk],
)?;
}
}};
}
update!(name);
update!(account);
update!(digest);
update!(enabled);
update!(verified);
update!(hide_address);
update!(receive_duplicates);
update!(receive_own_posts);
update!(receive_confirmation);
tx.commit()?;
Ok(())
}
/// Fetch account by pk.
pub fn account(&self, pk: i64) -> Result<Option<DbVal<Account>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM account WHERE pk = ?;")?;
let ret = stmt
.query_row(rusqlite::params![&pk], |row| {
let _pk: i64 = row.get("pk")?;
debug_assert_eq!(pk, _pk);
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Fetch account by address.
pub fn account_by_address(&self, address: &str) -> Result<Option<DbVal<Account>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM account WHERE address = ?;")?;
let ret = stmt
.query_row(rusqlite::params![&address], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Fetch all subscriptions of an account by primary key.
pub fn account_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE account = ?;")?;
let list_iter = stmt.query_map([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
account: row.get("account")?,
name: row.get("name")?,
digest: row.get("digest")?,
enabled: row.get("enabled")?,
verified: row.get("verified")?,
hide_address: row.get("hide_address")?,
receive_duplicates: row.get("receive_duplicates")?,
receive_own_posts: row.get("receive_own_posts")?,
receive_confirmation: row.get("receive_confirmation")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Fetch all accounts.
pub fn accounts(&self) -> Result<Vec<DbVal<Account>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM account ORDER BY pk ASC;")?;
let list_iter = stmt.query_map([], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Add account.
pub fn add_account(&self, new_val: Account) -> Result<DbVal<Account>> {
let mut stmt = self
.connection
.prepare(
"INSERT INTO account(name, address, public_key, password, enabled) VALUES(?, ?, \
?, ?, ?) RETURNING *;",
)
.unwrap();
let ret = stmt.query_row(
rusqlite::params![
&new_val.name,
&new_val.address,
&new_val.public_key,
&new_val.password,
&new_val.enabled,
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
Account {
pk,
name: row.get("name")?,
address: row.get("address")?,
public_key: row.get("public_key")?,
password: row.get("password")?,
enabled: row.get("enabled")?,
},
pk,
))
},
)?;
trace!("add_account {:?}.", &ret);
Ok(ret)
}
/// Remove an account by their address.
pub fn remove_account(&self, address: &str) -> Result<()> {
self.connection
.query_row(
"DELETE FROM account WHERE address = ? RETURNING *;",
rusqlite::params![&address],
|_| Ok(()),
)
.map_err(|err| {
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("account not found!"))
} else {
err.into()
}
})?;
Ok(())
}
/// Update an account.
pub fn update_account(&self, change_set: AccountChangeset) -> Result<()> {
let Some(acc) = self.account_by_address(&change_set.address)? else {
return Err(NotFound("account with this address not found!").into());
};
let pk = acc.pk;
if matches!(
change_set,
AccountChangeset {
address: _,
name: None,
public_key: None,
password: None,
enabled: None,
}
) {
return Ok(());
}
let AccountChangeset {
address: _,
name,
public_key,
password,
enabled,
} = change_set;
let tx = self.savepoint(Some(stringify!(update_account)))?;
macro_rules! update {
($field:tt) => {{
if let Some($field) = $field {
tx.connection.execute(
concat!(
"UPDATE account SET ",
stringify!($field),
" = ? WHERE pk = ?;"
),
rusqlite::params![&$field, &pk],
)?;
}
}};
}
update!(name);
update!(public_key);
update!(password);
update!(enabled);
tx.commit()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::*;
#[test]
fn test_subscription_ops() {
use tempfile::TempDir;
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
let list = db
.create_list(MailingList {
pk: -1,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
topics: vec![],
description: None,
archive_url: None,
})
.unwrap();
let secondary_list = db
.create_list(MailingList {
pk: -1,
name: "foobar chat2".into(),
id: "foo-chat2".into(),
address: "foo-chat2@example.com".into(),
topics: vec![],
description: None,
archive_url: None,
})
.unwrap();
for i in 0..4 {
let sub = db
.add_subscription(
list.pk(),
ListSubscription {
pk: -1,
list: list.pk(),
address: format!("{i}@example.com"),
account: None,
name: Some(format!("User{i}")),
digest: false,
hide_address: false,
receive_duplicates: false,
receive_own_posts: false,
receive_confirmation: false,
enabled: true,
verified: false,
},
)
.unwrap();
assert_eq!(db.list_subscription(list.pk(), sub.pk()).unwrap(), sub);
assert_eq!(
db.list_subscription_by_address(list.pk(), &sub.address)
.unwrap(),
sub
);
}
assert_eq!(db.accounts().unwrap(), vec![]);
assert_eq!(
db.remove_subscription(list.pk(), "nonexistent@example.com")
.map_err(|err| err.to_string())
.unwrap_err(),
NotFound("list or list owner not found!").to_string()
);
let cand = db
.add_candidate_subscription(
list.pk(),
ListSubscription {
pk: -1,
list: list.pk(),
address: "4@example.com".into(),
account: None,
name: Some("User4".into()),
digest: false,
hide_address: false,
receive_duplicates: false,
receive_own_posts: false,
receive_confirmation: false,
enabled: true,
verified: false,
},
)
.unwrap();
let accepted = db.accept_candidate_subscription(cand.pk()).unwrap();
assert_eq!(db.account(5).unwrap(), None);
assert_eq!(
db.remove_account("4@example.com")
.map_err(|err| err.to_string())
.unwrap_err(),
NotFound("account not found!").to_string()
);
let acc = db
.add_account(Account {
pk: -1,
name: accepted.name.clone(),
address: accepted.address.clone(),
public_key: None,
password: String::new(),
enabled: true,
})
.unwrap();
// Test [ref:add_account] SQL trigger (see schema.sql)
assert_eq!(
db.list_subscription(list.pk(), accepted.pk())
.unwrap()
.account,
Some(acc.pk())
);
// Test [ref:add_account_to_subscription] SQL trigger (see schema.sql)
let sub = db
.add_subscription(
secondary_list.pk(),
ListSubscription {
pk: -1,
list: secondary_list.pk(),
address: "4@example.com".into(),
account: None,
name: Some("User4".into()),
digest: false,
hide_address: false,
receive_duplicates: false,
receive_own_posts: false,
receive_confirmation: false,
enabled: true,
verified: true,
},
)
.unwrap();
assert_eq!(sub.account, Some(acc.pk()));
// Test [ref:verify_subscription_email] SQL trigger (see schema.sql)
assert!(!sub.verified);
assert_eq!(db.accounts().unwrap(), vec![acc.clone()]);
assert_eq!(
db.update_account(AccountChangeset {
address: "nonexistent@example.com".into(),
..AccountChangeset::default()
})
.map_err(|err| err.to_string())
.unwrap_err(),
NotFound("account with this address not found!").to_string()
);
assert_eq!(
db.update_account(AccountChangeset {
address: acc.address.clone(),
..AccountChangeset::default()
})
.map_err(|err| err.to_string()),
Ok(())
);
assert_eq!(
db.update_account(AccountChangeset {
address: acc.address.clone(),
enabled: Some(Some(false)),
..AccountChangeset::default()
})
.map_err(|err| err.to_string()),
Ok(())
);
assert!(!db.account(acc.pk()).unwrap().unwrap().enabled);
assert_eq!(
db.remove_account("4@example.com")
.map_err(|err| err.to_string()),
Ok(())
);
assert_eq!(db.accounts().unwrap(), vec![]);
}
}

View File

@ -1,370 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Named templates, for generated e-mail like confirmations, alerts etc.
//!
//! Template database model: [`Template`].
use log::trace;
use rusqlite::OptionalExtension;
use crate::{
errors::{ErrorKind::*, *},
Connection, DbVal,
};
/// A named template.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Template {
/// Database primary key.
pub pk: i64,
/// Name.
pub name: String,
/// Associated list foreign key, optional.
pub list: Option<i64>,
/// Subject template.
pub subject: Option<String>,
/// Extra headers template.
pub headers_json: Option<serde_json::Value>,
/// Body template.
pub body: String,
}
impl std::fmt::Display for Template {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl Template {
/// Template name for generic list help e-mail.
pub const GENERIC_HELP: &'static str = "generic-help";
/// Template name for generic failure e-mail.
pub const GENERIC_FAILURE: &'static str = "generic-failure";
/// Template name for generic success e-mail.
pub const GENERIC_SUCCESS: &'static str = "generic-success";
/// Template name for subscription confirmation e-mail.
pub const SUBSCRIPTION_CONFIRMATION: &'static str = "subscription-confirmation";
/// Template name for unsubscription confirmation e-mail.
pub const UNSUBSCRIPTION_CONFIRMATION: &'static str = "unsubscription-confirmation";
/// Template name for subscription request notice e-mail (for list owners).
pub const SUBSCRIPTION_REQUEST_NOTICE_OWNER: &'static str = "subscription-notice-owner";
/// Template name for subscription request acceptance e-mail (for the
/// candidates).
pub const SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT: &'static str =
"subscription-notice-candidate-accept";
/// Template name for admin notices.
pub const ADMIN_NOTICE: &'static str = "admin-notice";
/// Render a message body from a saved named template.
pub fn render(&self, context: minijinja::value::Value) -> Result<melib::Draft> {
use melib::{Draft, HeaderName};
let env = minijinja::Environment::new();
let mut draft: Draft = Draft {
body: env.render_named_str("body", &self.body, &context)?,
..Draft::default()
};
if let Some(ref subject) = self.subject {
draft.headers.insert(
HeaderName::SUBJECT,
env.render_named_str("subject", subject, &context)?,
);
}
Ok(draft)
}
/// Template name for generic failure e-mail.
pub fn default_generic_failure() -> Self {
Self {
pk: -1,
name: Self::GENERIC_FAILURE.to_string(),
list: None,
subject: Some(
"{{ subject if subject else \"Your e-mail was not processed successfully.\" }}"
.to_string(),
),
headers_json: None,
body: "{{ details|safe if details else \"The list owners and administrators have been \
notified.\" }}"
.to_string(),
}
}
/// Create a plain template for generic success e-mails.
pub fn default_generic_success() -> Self {
Self {
pk: -1,
name: Self::GENERIC_SUCCESS.to_string(),
list: None,
subject: Some(
"{{ subject if subject else \"Your e-mail was processed successfully.\" }}"
.to_string(),
),
headers_json: None,
body: "{{ details|safe if details else \"\" }}".to_string(),
}
}
/// Create a plain template for subscription confirmation.
pub fn default_subscription_confirmation() -> Self {
Self {
pk: -1,
name: Self::SUBSCRIPTION_CONFIRMATION.to_string(),
list: None,
subject: Some(
"{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
%}You have successfully subscribed to {{ list.name if list.name else list.id \
}}{% else %}You have successfully subscribed to this list{% endif %}."
.to_string(),
),
headers_json: None,
body: "{{ details|safe if details else \"\" }}".to_string(),
}
}
/// Create a plain template for unsubscription confirmations.
pub fn default_unsubscription_confirmation() -> Self {
Self {
pk: -1,
name: Self::UNSUBSCRIPTION_CONFIRMATION.to_string(),
list: None,
subject: Some(
"{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
%}You have successfully unsubscribed from {{ list.name if list.name else list.id \
}}{% else %}You have successfully unsubscribed from this list{% endif %}."
.to_string(),
),
headers_json: None,
body: "{{ details|safe if details else \"\" }}".to_string(),
}
}
/// Create a plain template for admin notices.
pub fn default_admin_notice() -> Self {
Self {
pk: -1,
name: Self::ADMIN_NOTICE.to_string(),
list: None,
subject: Some(
"{% if list %}An error occured with list {{ list.id }}{% else %}An error \
occured{% endif %}"
.to_string(),
),
headers_json: None,
body: "{{ details|safe if details else \"\" }}".to_string(),
}
}
/// Create a plain template for subscription requests for list owners.
pub fn default_subscription_request_owner() -> Self {
Self {
pk: -1,
name: Self::SUBSCRIPTION_REQUEST_NOTICE_OWNER.to_string(),
list: None,
subject: Some("Subscription request for {{ list.id }}".to_string()),
headers_json: None,
body: "Candidate {{ candidate.name if candidate.name else \"\" }} <{{ \
candidate.address }}> Primary key: {{ candidate.pk }}\n\n{{ details|safe if \
details else \"\" }}"
.to_string(),
}
}
/// Create a plain template for subscription requests for candidates.
pub fn default_subscription_request_candidate_accept() -> Self {
Self {
pk: -1,
name: Self::SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT.to_string(),
list: None,
subject: Some("Your subscription to {{ list.id }} is now active.".to_string()),
headers_json: None,
body: "{{ details|safe if details else \"\" }}".to_string(),
}
}
/// Create a plain template for generic list help replies.
pub fn default_generic_help() -> Self {
Self {
pk: -1,
name: Self::GENERIC_HELP.to_string(),
list: None,
subject: Some("{{ subject if subject else \"Help for mailing list\" }}".to_string()),
headers_json: None,
body: "{{ details }}".to_string(),
}
}
}
impl Connection {
/// Fetch all.
pub fn fetch_templates(&self) -> Result<Vec<DbVal<Template>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM template ORDER BY pk;")?;
let iter = stmt.query_map(rusqlite::params![], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Template {
pk,
name: row.get("name")?,
list: row.get("list")?,
subject: row.get("subject")?,
headers_json: row.get("headers_json")?,
body: row.get("body")?,
},
pk,
))
})?;
let mut ret = vec![];
for templ in iter {
let templ = templ?;
ret.push(templ);
}
Ok(ret)
}
/// Fetch a named template.
pub fn fetch_template(
&self,
template: &str,
list_pk: Option<i64>,
) -> Result<Option<DbVal<Template>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM template WHERE name = ? AND list IS ?;")?;
let ret = stmt
.query_row(rusqlite::params![&template, &list_pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Template {
pk,
name: row.get("name")?,
list: row.get("list")?,
subject: row.get("subject")?,
headers_json: row.get("headers_json")?,
body: row.get("body")?,
},
pk,
))
})
.optional()?;
if ret.is_none() && list_pk.is_some() {
let mut stmt = self
.connection
.prepare("SELECT * FROM template WHERE name = ? AND list IS NULL;")?;
Ok(stmt
.query_row(rusqlite::params![&template], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Template {
pk,
name: row.get("name")?,
list: row.get("list")?,
subject: row.get("subject")?,
headers_json: row.get("headers_json")?,
body: row.get("body")?,
},
pk,
))
})
.optional()?)
} else {
Ok(ret)
}
}
/// Insert a named template.
pub fn add_template(&self, template: Template) -> Result<DbVal<Template>> {
let mut stmt = self.connection.prepare(
"INSERT INTO template(name, list, subject, headers_json, body) VALUES(?, ?, ?, ?, ?) \
RETURNING *;",
)?;
let ret = stmt
.query_row(
rusqlite::params![
&template.name,
&template.list,
&template.subject,
&template.headers_json,
&template.body
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
Template {
pk,
name: row.get("name")?,
list: row.get("list")?,
subject: row.get("subject")?,
headers_json: row.get("headers_json")?,
body: row.get("body")?,
},
pk,
))
},
)
.map_err(|err| {
if matches!(
err,
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ffi::ErrorCode::ConstraintViolation,
extended_code: 787
},
_
)
) {
Error::from(err).chain_err(|| NotFound("Could not find a list with this pk."))
} else {
err.into()
}
})?;
trace!("add_template {:?}.", &ret);
Ok(ret)
}
/// Remove a named template.
pub fn remove_template(&self, template: &str, list_pk: Option<i64>) -> Result<Template> {
let mut stmt = self
.connection
.prepare("DELETE FROM template WHERE name = ? AND list IS ? RETURNING *;")?;
let ret = stmt.query_row(rusqlite::params![&template, &list_pk], |row| {
Ok(Template {
pk: -1,
name: row.get("name")?,
list: row.get("list")?,
subject: row.get("subject")?,
headers_json: row.get("headers_json")?,
body: row.get("body")?,
})
})?;
trace!(
"remove_template {} list_pk {:?} {:?}.",
template,
&list_pk,
&ret
);
Ok(ret)
}
}

View File

@ -1,145 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
#[test]
fn test_accounts() {
init_stderr_logging();
const SSH_KEY: &[u8] = include_bytes!("./ssh_key.pub");
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let lists = db.lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0], foo_chat);
let post_policy = db
.set_list_post_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
let db = db.untrusted();
let subscribe_bytes = b"From: Name <user@example.com>
To: <foo-chat+subscribe@example.com>
Subject: subscribe
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
";
let envelope =
melib::Envelope::from_bytes(subscribe_bytes, None).expect("Could not parse message");
db.post(&envelope, subscribe_bytes, /* dry_run */ false)
.unwrap();
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
assert_eq!(db.account_by_address("user@example.com").unwrap(), None);
println!(
"Check that sending a password request without having an account creates the account."
);
const PASSWORD_REQ: &[u8] = b"From: Name <user@example.com>
To: <foo-chat+request@example.com>
Subject: password
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/plain; charset=ascii
Content-Transfer-Encoding: 8bit
MIME-Version: 1.0
";
let mut set_password_bytes = PASSWORD_REQ.to_vec();
set_password_bytes.extend(SSH_KEY.iter().cloned());
let envelope =
melib::Envelope::from_bytes(&set_password_bytes, None).expect("Could not parse message");
db.post(&envelope, &set_password_bytes, /* dry_run */ false)
.unwrap();
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
let acc = db.account_by_address("user@example.com").unwrap().unwrap();
assert_eq!(
acc.password.as_bytes(),
SSH_KEY,
"SSH public key / passwords didn't match. Account has {:?} but expected {:?}",
String::from_utf8_lossy(acc.password.as_bytes()),
String::from_utf8_lossy(SSH_KEY)
);
println!("Check that sending a password request with an account updates the password field.");
let mut set_password_bytes = PASSWORD_REQ.to_vec();
set_password_bytes.push(b'a');
set_password_bytes.extend(SSH_KEY.iter().cloned());
let envelope =
melib::Envelope::from_bytes(&set_password_bytes, None).expect("Could not parse message");
db.post(&envelope, &set_password_bytes, /* dry_run */ false)
.unwrap();
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
let acc = db.account_by_address("user@example.com").unwrap().unwrap();
assert!(
acc.password.as_bytes() != SSH_KEY,
"SSH public key / password should have changed.",
);
}

View File

@ -1,113 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{models::*, Configuration, Connection, ErrorKind, SendMail};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
#[test]
fn test_authorizer() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap();
assert!(db.lists().unwrap().is_empty());
for err in [
db.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap_err(),
db.remove_list_owner(1, 1).unwrap_err(),
db.remove_list_post_policy(1, 1).unwrap_err(),
db.set_list_post_policy(PostPolicy {
pk: 0,
list: 1,
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.unwrap_err(),
] {
assert_eq!(
err.kind().to_string(),
ErrorKind::Sql(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::AuthorizationForStatementDenied,
extended_code: 23,
},
Some("not authorized".into()),
))
.to_string()
);
}
assert!(db.lists().unwrap().is_empty());
let db = db.trusted();
for ok in [
db.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.map(|_| ()),
db.add_list_owner(ListOwner {
pk: 0,
list: 1,
address: String::new(),
name: None,
})
.map(|_| ()),
db.set_list_post_policy(PostPolicy {
pk: 0,
list: 1,
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.map(|_| ()),
db.remove_list_post_policy(1, 1).map(|_| ()),
db.remove_list_owner(1, 1).map(|_| ()),
] {
ok.unwrap();
}
}

View File

@ -1,73 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{models::*, Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
#[test]
fn test_init_empty() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap();
assert!(db.lists().unwrap().is_empty());
}
#[test]
fn test_list_creation() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let lists = db.lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0], foo_chat);
}

View File

@ -1,96 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{melib, models::*, queue::Queue, Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
use melib::smtp::*;
SmtpServerConf {
hostname: "127.0.0.1".into(),
port: 8825,
envelope_from: "foo-chat@example.com".into(),
auth: SmtpAuth::None,
security: SmtpSecurity::None,
extensions: Default::default(),
}
}
#[test]
fn test_error_queue() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::Smtp(get_smtp_conf()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let post_policy = db
.set_list_post_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
// drop privileges
let db = db.untrusted();
let input_bytes = include_bytes!("./test_sample_longmessage.eml");
let envelope = melib::Envelope::from_bytes(input_bytes, None).expect("Could not parse message");
db.post(&envelope, input_bytes, /* dry_run */ false)
.expect("Got unexpected error");
let out = db.queue(Queue::Out).unwrap();
assert_eq!(out.len(), 1);
const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
assert_eq!(
out[0]
.comment
.as_ref()
.and_then(|c| c.get(..COMMENT_PREFIX.len())),
Some(COMMENT_PREFIX)
);
}

View File

@ -1,343 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::fs::{File, OpenOptions};
use mailpot::{Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
include!("../build/make_migrations.rs");
#[test]
fn test_init_empty() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
let migrations = Connection::MIGRATIONS;
if migrations.is_empty() {
return;
}
let version = db.schema_version().unwrap();
assert_eq!(version, migrations[migrations.len() - 1].0);
db.migrate(version, migrations[0].0).unwrap();
db.migrate(migrations[0].0, version).unwrap();
}
trait ConnectionExt {
fn schema_version(&self) -> Result<u32, rusqlite::Error>;
fn migrate(
&mut self,
from: u32,
to: u32,
migrations: &[(u32, &str, &str)],
) -> Result<(), rusqlite::Error>;
}
impl ConnectionExt for rusqlite::Connection {
fn schema_version(&self) -> Result<u32, rusqlite::Error> {
self.prepare("SELECT user_version FROM pragma_user_version;")?
.query_row([], |row| {
let v: u32 = row.get(0)?;
Ok(v)
})
}
fn migrate(
&mut self,
mut from: u32,
to: u32,
migrations: &[(u32, &str, &str)],
) -> Result<(), rusqlite::Error> {
if from == to {
return Ok(());
}
let undo = from > to;
let tx = self.transaction()?;
loop {
log::trace!(
"exec migration from {from} to {to}, type: {}do",
if undo { "un" } else { "re" }
);
if undo {
log::trace!("{}", migrations[from as usize - 1].2);
tx.execute_batch(migrations[from as usize - 1].2)?;
from -= 1;
if from == to {
break;
}
} else {
if from != 0 {
log::trace!("{}", migrations[from as usize - 1].1);
tx.execute_batch(migrations[from as usize - 1].1)?;
}
from += 1;
if from == to + 1 {
break;
}
}
}
tx.pragma_update(
None,
"user_version",
if to == 0 {
0
} else {
migrations[to as usize - 1].0
},
)?;
tx.commit()?;
Ok(())
}
}
const FIRST_SCHEMA: &str = r#"
PRAGMA foreign_keys = true;
PRAGMA encoding = 'UTF-8';
PRAGMA schema_version = 0;
CREATE TABLE IF NOT EXISTS person (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT,
address TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
);
"#;
const MIGRATIONS: &[(u32, &str, &str)] = &[
(
1,
"ALTER TABLE PERSON ADD COLUMN interests TEXT;",
"ALTER TABLE PERSON DROP COLUMN interests;",
),
(
2,
"CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);",
"DROP TABLE hobby;",
),
(
3,
"ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;",
"ALTER TABLE PERSON DROP COLUMN main_hobby;",
),
];
#[test]
fn test_migration_gen() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let in_path = tmp_dir.path().join("migrations");
std::fs::create_dir(&in_path).unwrap();
let out_path = tmp_dir.path().join("migrations.txt");
for (num, redo, undo) in MIGRATIONS.iter() {
let mut redo_file = File::options()
.write(true)
.create(true)
.truncate(true)
.open(&in_path.join(&format!("{num:03}.sql")))
.unwrap();
redo_file.write_all(redo.as_bytes()).unwrap();
redo_file.flush().unwrap();
let mut undo_file = File::options()
.write(true)
.create(true)
.truncate(true)
.open(&in_path.join(&format!("{num:03}.undo.sql")))
.unwrap();
undo_file.write_all(undo.as_bytes()).unwrap();
undo_file.flush().unwrap();
}
make_migrations(&in_path, &out_path, &mut vec![]);
let output = std::fs::read_to_string(&out_path).unwrap();
assert_eq!(&output.replace([' ', '\n'], ""), &r###"//(user_version, redo sql, undo sql
&[(1,r##"ALTER TABLE PERSON ADD COLUMN interests TEXT;"##,r##"ALTER TABLE PERSON DROP COLUMN interests;"##),(2,r##"CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);"##,r##"DROP TABLE hobby;"##),(3,r##"ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;"##,r##"ALTER TABLE PERSON DROP COLUMN main_hobby;"##),]"###.replace([' ', '\n'], ""));
}
#[test]
#[should_panic]
fn test_migration_gen_panic() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let in_path = tmp_dir.path().join("migrations");
std::fs::create_dir(&in_path).unwrap();
let out_path = tmp_dir.path().join("migrations.txt");
for (num, redo, undo) in MIGRATIONS.iter().skip(1) {
let mut redo_file = File::options()
.write(true)
.create(true)
.truncate(true)
.open(&in_path.join(&format!("{num:03}.sql")))
.unwrap();
redo_file.write_all(redo.as_bytes()).unwrap();
redo_file.flush().unwrap();
let mut undo_file = File::options()
.write(true)
.create(true)
.truncate(true)
.open(&in_path.join(&format!("{num:03}.undo.sql")))
.unwrap();
undo_file.write_all(undo.as_bytes()).unwrap();
undo_file.flush().unwrap();
}
make_migrations(&in_path, &out_path, &mut vec![]);
let output = std::fs::read_to_string(&out_path).unwrap();
assert_eq!(&output.replace([' ','\n'], ""), &r#"//(user_version, redo sql, undo sql
&[(1,"ALTER TABLE PERSON ADD COLUMN interests TEXT;","ALTER TABLE PERSON DROP COLUMN interests;"),(2,"CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);","DROP TABLE hobby;"),(3,"ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;","ALTER TABLE PERSON DROP COLUMN main_hobby;"),]"#.replace([' ', '\n'], ""));
}
#[test]
fn test_migration() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("migr.db");
let mut conn = rusqlite::Connection::open(db_path.to_str().unwrap()).unwrap();
conn.execute_batch(FIRST_SCHEMA).unwrap();
conn.execute_batch(
"INSERT INTO person(name,address) VALUES('John Doe', 'johndoe@example.com');",
)
.unwrap();
let version = conn.schema_version().unwrap();
log::trace!("initial schema version is {}", version);
//assert_eq!(version, migrations[migrations.len() - 1].0);
conn.migrate(version, MIGRATIONS.last().unwrap().0, MIGRATIONS)
.unwrap();
/*
* CREATE TABLE sqlite_schema (
type text,
name text,
tbl_name text,
rootpage integer,
sql text
);
*/
let get_sql = |table: &str, conn: &rusqlite::Connection| -> String {
conn.prepare("SELECT sql FROM sqlite_schema WHERE name = ?;")
.unwrap()
.query_row([table], |row| {
let sql: String = row.get(0)?;
Ok(sql)
})
.unwrap()
};
let strip_ws = |sql: &str| -> String { sql.replace([' ', '\n'], "") };
let person_sql: String = get_sql("person", &conn);
assert_eq!(
&strip_ws(&person_sql),
&strip_ws(
r#"
CREATE TABLE person (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT,
address TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
interests TEXT,
main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL
)"#
)
);
let hobby_sql: String = get_sql("hobby", &conn);
assert_eq!(
&strip_ws(&hobby_sql),
&strip_ws(
r#"CREATE TABLE hobby (
pk INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL
)"#
)
);
conn.execute_batch(
r#"
INSERT INTO hobby(title) VALUES('fishing');
INSERT INTO hobby(title) VALUES('reading books');
INSERT INTO hobby(title) VALUES('running');
INSERT INTO hobby(title) VALUES('forest walks');
UPDATE person SET main_hobby = hpk FROM (SELECT pk AS hpk FROM hobby LIMIT 1) WHERE name = 'John Doe';
"#
)
.unwrap();
log::trace!(
"John Doe's main hobby is {:?}",
conn.prepare(
"SELECT pk, title FROM hobby WHERE EXISTS (SELECT 1 FROM person AS p WHERE \
p.main_hobby = pk);"
)
.unwrap()
.query_row([], |row| {
let pk: i64 = row.get(0)?;
let title: String = row.get(1)?;
Ok((pk, title))
})
.unwrap()
);
conn.migrate(MIGRATIONS.last().unwrap().0, 0, MIGRATIONS)
.unwrap();
assert_eq!(
conn.prepare("SELECT sql FROM sqlite_schema WHERE name = 'hobby';")
.unwrap()
.query_row([], |row| { row.get::<_, String>(0) })
.unwrap_err(),
rusqlite::Error::QueryReturnedNoRows
);
let person_sql: String = get_sql("person", &conn);
assert_eq!(
&strip_ws(&person_sql),
&strip_ws(
r#"
CREATE TABLE person (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT,
address TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
)"#
)
);
}

View File

@ -1,223 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2023 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use jsonschema::JSONSchema;
use mailpot::{Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use serde_json::{json, Value};
use tempfile::TempDir;
#[test]
fn test_settings_json() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&db_path, perms).unwrap();
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
let list = db.lists().unwrap().remove(0);
let archived_at_link_settings_schema =
std::fs::read_to_string("./settings_json_schemas/archivedatlink.json").unwrap();
println!("Testing that inserting settings works…");
let (settings_pk, settings_val, last_modified): (i64, Value, i64) = {
let mut stmt = db
.connection
.prepare(
"INSERT INTO list_settings_json(name, list, value) \
VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value, last_modified;",
)
.unwrap();
stmt.query_row(
rusqlite::params![
&list.pk(),
&json!({
"template": "https://www.example.com/{{msg_id}}.html",
"preserve_carets": false
}),
],
|row| {
let pk: i64 = row.get("pk")?;
let value: Value = row.get("value")?;
let last_modified: i64 = row.get("last_modified")?;
Ok((pk, value, last_modified))
},
)
.unwrap()
};
db.connection
.execute_batch("UPDATE list_settings_json SET is_valid = 1;")
.unwrap();
println!("Testing that schema is actually valid…");
let schema: Value = serde_json::from_str(&archived_at_link_settings_schema).unwrap();
let compiled = JSONSchema::compile(&schema).expect("A valid schema");
if let Err(errors) = compiled.validate(&settings_val) {
for err in errors {
eprintln!("Error: {err}");
}
panic!("Could not validate settings.");
};
println!("Testing that inserting invalid settings aborts…");
{
let mut stmt = db
.connection
.prepare(
"INSERT OR REPLACE INTO list_settings_json(name, list, value) \
VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value;",
)
.unwrap();
assert_eq!(
"new settings value is not valid according to the json schema. Rolling back \
transaction.",
&stmt
.query_row(
rusqlite::params![
&list.pk(),
&json!({
"template": "https://www.example.com/msg-id}.html" // should be msg_id
}),
],
|row| {
let pk: i64 = row.get("pk")?;
let value: Value = row.get("value")?;
Ok((pk, value))
},
)
.unwrap_err()
.to_string()
);
};
println!("Testing that updating settings with invalid value aborts…");
{
let mut stmt = db
.connection
.prepare(
"UPDATE list_settings_json SET value = ? WHERE name = 'ArchivedAtLinkSettings' \
RETURNING pk, value;",
)
.unwrap();
assert_eq!(
"new settings value is not valid according to the json schema. Rolling back \
transaction.",
&stmt
.query_row(
rusqlite::params![&json!({
"template": "https://www.example.com/msg-id}.html" // should be msg_id
}),],
|row| {
let pk: i64 = row.get("pk")?;
let value: Value = row.get("value")?;
Ok((pk, value))
},
)
.unwrap_err()
.to_string()
);
};
std::thread::sleep(std::time::Duration::from_millis(1000));
println!("Finally, testing that updating schema reverifies settings…");
{
let mut stmt = db
.connection
.prepare(
"UPDATE settings_json_schema SET id = ? WHERE id = 'ArchivedAtLinkSettings' \
RETURNING pk;",
)
.unwrap();
stmt.query_row([&"ArchivedAtLinkSettingsv2"], |_| Ok(()))
.unwrap();
};
let (new_name, is_valid, new_last_modified): (String, bool, i64) = {
let mut stmt = db
.connection
.prepare("SELECT name, is_valid, last_modified from list_settings_json WHERE pk = ?;")
.unwrap();
stmt.query_row([&settings_pk], |row| {
Ok((
row.get("name")?,
row.get("is_valid")?,
row.get("last_modified")?,
))
})
.unwrap()
};
assert_eq!(&new_name, "ArchivedAtLinkSettingsv2");
assert!(is_valid);
assert!(new_last_modified != last_modified);
}
#[test]
fn test_settings_json_schemas() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&db_path, perms).unwrap();
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
let schemas: Vec<String> = {
let mut stmt = db
.connection
.prepare("SELECT value FROM list_settings_json;")
.unwrap();
let iter = stmt
.query_map([], |row| {
let value: String = row.get("value")?;
Ok(value)
})
.unwrap();
let mut ret = vec![];
for item in iter {
ret.push(item.unwrap());
}
ret
};
println!("Testing that schemas are valid…");
for schema in schemas {
let schema: Value = serde_json::from_str(&schema).unwrap();
let _compiled = JSONSchema::compile(&schema).expect("A valid schema");
}
}

View File

@ -1,284 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use log::{trace, warn};
use mailpot::{melib, models::*, queue::Queue, Configuration, Connection, SendMail};
use mailpot_tests::*;
use melib::smol;
use tempfile::TempDir;
#[test]
fn test_smtp() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8825").build();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let post_policy = db
.set_list_post_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
let input_bytes = include_bytes!("./test_sample_longmessage.eml");
match melib::Envelope::from_bytes(input_bytes, None) {
Ok(envelope) => {
// eprintln!("envelope {:?}", &envelope);
db.post(&envelope, input_bytes, /* dry_run */ false)
.expect("Got unexpected error");
{
let out = db.queue(Queue::Out).unwrap();
assert_eq!(out.len(), 1);
const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
assert_eq!(
out[0]
.comment
.as_ref()
.and_then(|c| c.get(..COMMENT_PREFIX.len())),
Some(COMMENT_PREFIX)
);
}
db.add_subscription(
foo_chat.pk(),
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "paaoejunp@example.com".into(),
name: Some("Cardholder Name".into()),
account: None,
digest: false,
verified: true,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
)
.unwrap();
db.add_subscription(
foo_chat.pk(),
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "manos@example.com".into(),
name: Some("Manos Hands".into()),
account: None,
digest: false,
verified: true,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
)
.unwrap();
db.post(&envelope, input_bytes, /* dry_run */ false)
.unwrap();
}
Err(err) => {
panic!("Could not parse message: {}", err);
}
}
let messages = db.delete_from_queue(Queue::Out, vec![]).unwrap();
eprintln!("Queue out has {} messages.", messages.len());
let conn_future = db.new_smtp_connection().unwrap();
smol::future::block_on(smol::spawn(async move {
let mut conn = conn_future.await.unwrap();
for msg in messages {
Connection::submit(&mut conn, &msg, /* dry_run */ false)
.await
.unwrap();
}
}));
let stored = smtp_handler.stored.lock().unwrap();
assert_eq!(stored.len(), 3);
assert_eq!(&stored[0].0, "paaoejunp@example.com");
assert_eq!(
&stored[0].1.subject(),
"Your post to foo-chat was rejected."
);
assert_eq!(
&stored[1].1.subject(),
"[foo-chat] thankful that I had the chance to written report, that I could learn and let \
alone the chance $4454.32"
);
assert_eq!(
&stored[2].1.subject(),
"[foo-chat] thankful that I had the chance to written report, that I could learn and let \
alone the chance $4454.32"
);
}
#[test]
fn test_smtp_mailcrab() {
use std::env;
init_stderr_logging();
fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
use melib::smtp::*;
SmtpServerConf {
hostname: "127.0.0.1".into(),
port: 1025,
envelope_from: "foo-chat@example.com".into(),
auth: SmtpAuth::None,
security: SmtpSecurity::None,
extensions: Default::default(),
}
}
let Ok(mailcrab_ip) = env::var("MAILCRAB_IP") else {
warn!("MAILCRAB_IP env var not set, is mailcrab server running?");
return;
};
let mailcrab_port = env::var("MAILCRAB_PORT").unwrap_or("1080".to_string());
let api_uri = format!("http://{mailcrab_ip}:{mailcrab_port}/api/messages");
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::Smtp(get_smtp_conf()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let post_policy = db
.set_list_post_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
let input_bytes = include_bytes!("./test_sample_longmessage.eml");
match melib::Envelope::from_bytes(input_bytes, None) {
Ok(envelope) => {
match db
.post(&envelope, input_bytes, /* dry_run */ false)
.unwrap_err()
.kind()
{
mailpot::ErrorKind::PostRejected(reason) => {
trace!("Non-subscription post succesfully rejected: '{reason}'");
}
other => panic!("Got unexpected error: {}", other),
}
db.add_subscription(
foo_chat.pk(),
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "paaoejunp@example.com".into(),
name: Some("Cardholder Name".into()),
account: None,
digest: false,
verified: true,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
)
.unwrap();
db.add_subscription(
foo_chat.pk(),
ListSubscription {
pk: 0,
list: foo_chat.pk(),
address: "manos@example.com".into(),
name: Some("Manos Hands".into()),
account: None,
digest: false,
verified: true,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
)
.unwrap();
db.post(&envelope, input_bytes, /* dry_run */ false)
.unwrap();
}
Err(err) => {
panic!("Could not parse message: {}", err);
}
}
let mails: String = reqwest::blocking::get(api_uri).unwrap().text().unwrap();
trace!("mails: {}", mails);
}

View File

@ -1,38 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEA9WwdJs/OhxhDoXqSCJHc3Ywrc3d2ATzfi8OVmlkm3kLSlGIOBefZ
nWf0ew+mU8tWIg0+U6/skh9tDvZ8jv8V+jsFhlP257eWoMNj6C8rBoXVOr5aUXsvyiboO+
G9ecu2W9KKDSXlOROA7ucmKx2sUqNdB6HwhnwhiC2Lqzm7utNVc9FLUkyArhW9NbdklsmS
ocDPzl/WnE3l3xAsaTQTRzWXtXTjit27MqIsh7Ld9q+pqH5DYlam213STE/0Qv4GZdjLTd
IRoHQ8VLZXsk8ppkRxUCYU4tNIydfwx/RxGG5f8wTbuy096CjJfDcxKsQLPOPPyzhStv3h
nhHWIP8IIvPXfAUwoTG6o5Z7Czz0kl/CXOATvEStJccj6X13YmaIIDWSmc5JmelDGDj1GR
54G3GbimzrCG+nSrhfbwenPSefzcnxPSdROdo7SSt0fgMVxfOi+rVrsr4KWMQUq7e1LYgc
Wir90g6W4V0S4dRRBnD0A9GuFRcpqPPnz+7oAH3tAAAFiKCeR3ygnkd8AAAAB3NzaC1yc2
EAAAGBAPVsHSbPzocYQ6F6kgiR3N2MK3N3dgE834vDlZpZJt5C0pRiDgXn2Z1n9HsPplPL
ViINPlOv7JIfbQ72fI7/Ffo7BYZT9ue3lqDDY+gvKwaF1Tq+WlF7L8om6DvhvXnLtlvSig
0l5TkTgO7nJisdrFKjXQeh8IZ8IYgti6s5u7rTVXPRS1JMgK4VvTW3ZJbJkqHAz85f1pxN
5d8QLGk0E0c1l7V044rduzKiLIey3favqah+Q2JWpttd0kxP9EL+BmXYy03SEaB0PFS2V7
JPKaZEcVAmFOLTSMnX8Mf0cRhuX/ME27stPegoyXw3MSrECzzjz8s4Urb94Z4R1iD/CCLz
13wFMKExuqOWews89JJfwlzgE7xErSXHI+l9d2JmiCA1kpnOSZnpQxg49RkeeBtxm4ps6w
hvp0q4X28Hpz0nn83J8T0nUTnaO0krdH4DFcXzovq1a7K+CljEFKu3tS2IHFoq/dIOluFd
EuHUUQZw9APRrhUXKajz58/u6AB97QAAAAMBAAEAAAGBAJYL13bXLimiSBb93TKoGyTIgf
hCXT88fF/y4BBR2VWh/SUDHhe2PHHkELD8THCGrM580lJQCI7976tqP5Udl845L5OE2jup
HsqDKx3VWLTQNiGIJ6gRbJJnXyzdQv6n8YIKIqUPOim/JuDpKYjKx4RupH36IBfY5JdhYT
b6QTBj7Ka2mxph83p7iAbDbRhTfPav71z5czh018mdFcnsMK0ksvAZ2tQX5E98n0UHsnUT
yOJe78u7tp//qIdHiss6inRPKsWNkLk9fgzUAAfUu0GmJ5QCfu7RWVO6bXUk3TbgmxO40u
jmubL97BQTniQqs/BRCYhIDj7bEX9+QB5ck2K9WseD2ODlBW3J87qkVfhix/oP6NES2X2s
SHfNbDDagrbbweZJ96DXrRPpwV3u0Ez0iDEyxX4c++afT/vMN9kukIEf+GcHoJ2a+jmpZ7
nDvX4qOBsYQQvaUMBjkaZX8rW/vmRk7ocX6OKZe+h/UjcusyDszxbAcJ+IbpW1bCAk8QAA
AMEA7WBH3PksQx+8ibGHMstri6XWaB3U10SRm8NjW2CLmIdLPIn2QZ7+jhVLN6Lwj6pAOB
J2ihYh9CnzKtJA7sPe8EUvoLFSR2eTzxU2blUcDPUF2etUi+6jZsaYIWo/OrFSs28KZaVB
RsddoQbG2e9xaNWGqBVGogD1dgpAsdUau9kUcKjECxrtuzms97C9856rT9AjI3OroEBaVy
tivu9JZ30bJE8AYB6+diDJBvFZQM+ihi95n7sZrz8kBXvUiPwhAAAAwQD9NimhT36bbKSx
k7i6OCSzW079GOgr9YWeX43shEpdENosqwc8SjfuYRTPutvpbAkyeYa6k6QPR1WXWW2dFR
zslYPxBtUuiTosvOKjCxg2uG/xd68ha/AJRYJMVriMd/vWAy3fKv3k9ZeBLTJsAMfDVtOp
Q1sbLkUY4KyTeL0oGObzV1rJ8iyA3vJqfA9VolC4T1QI6q2BxPcNOX2r14fYet3a/kSI2+
aSl7Guonc5V5E716gcuj7w87AXZqDcLDsAAADBAPgf/gfY1rN269TN2CpudEIM4T5c6vl2
/6E1+49xkUDV6DDllQCM4ZJ7oTzu6hkWOYe9AAqgmkSYq0qGA2JT96Mh5qQSxj51p6z1CI
udoPxMG7kgQQYcEFiAd7NZEPxGY34pwCG73m9DeJt5hIZR6YQBZVKJsFOrlXAni9ambb2c
9YbMSAyFazmpU2uu2X8YRUIjB2C0ggFDUDRilK/ssWxX+HiPU+2woaxemcuK0kWEC02wXo
bEX7D3T3mJDvVj9wAAAA9lcGlseXNAY29tcG91bmQBAg==
-----END OPENSSH PRIVATE KEY-----

View File

@ -1 +0,0 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD1bB0mz86HGEOhepIIkdzdjCtzd3YBPN+Lw5WaWSbeQtKUYg4F59mdZ/R7D6ZTy1YiDT5Tr+ySH20O9nyO/xX6OwWGU/bnt5agw2PoLysGhdU6vlpRey/KJug74b15y7Zb0ooNJeU5E4Du5yYrHaxSo10HofCGfCGILYurObu601Vz0UtSTICuFb01t2SWyZKhwM/OX9acTeXfECxpNBNHNZe1dOOK3bsyoiyHst32r6mofkNiVqbbXdJMT/RC/gZl2MtN0hGgdDxUtleyTymmRHFQJhTi00jJ1/DH9HEYbl/zBNu7LT3oKMl8NzEqxAs848/LOFK2/eGeEdYg/wgi89d8BTChMbqjlnsLPPSSX8Jc4BO8RK0lxyPpfXdiZoggNZKZzkmZ6UMYOPUZHngbcZuKbOsIb6dKuF9vB6c9J5/NyfE9J1E52jtJK3R+AxXF86L6tWuyvgpYxBSrt7UtiBxaKv3SDpbhXRLh1FEGcPQD0a4VFymo8+fP7ugAfe0= epilys@localhost

View File

@ -1,330 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn test_list_subscription() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let lists = db.lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0], foo_chat);
let post_policy = db
.set_list_post_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
let db = db.untrusted();
let post_bytes = b"From: Name <user@example.com>
To: <foo-chat@example.com>
Subject: This is a post
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
eT48L2h0bWw+
";
let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
db.post(&envelope, post_bytes, /* dry_run */ false)
.expect("Got unexpected error");
let out = db.queue(Queue::Out).unwrap();
assert_eq!(out.len(), 1);
const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
assert_eq!(
out[0]
.comment
.as_ref()
.and_then(|c| c.get(..COMMENT_PREFIX.len())),
Some(COMMENT_PREFIX)
);
let subscribe_bytes = b"From: Name <user@example.com>
To: <foo-chat+subscribe@example.com>
Subject: subscribe
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
";
let envelope =
melib::Envelope::from_bytes(subscribe_bytes, None).expect("Could not parse message");
db.post(&envelope, subscribe_bytes, /* dry_run */ false)
.unwrap();
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
assert_eq!(db.queue(Queue::Out).unwrap().len(), 2);
let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
assert_eq!(db.queue(Queue::Out).unwrap().len(), 2);
assert_eq!(db.list_posts(foo_chat.pk(), None).unwrap().len(), 1);
}
#[test]
fn test_post_rejection() {
init_stderr_logging();
const ANNOUNCE_ONLY_PREFIX: Option<&str> =
Some("PostAction::Reject { reason: You are not allowed to post on this list.");
const APPROVAL_ONLY_PREFIX: Option<&str> = Some(
"PostAction::Defer { reason: Your posting has been deferred. Approval from the list's \
moderators",
);
for (q, mut post_policy) in [
(
[(Queue::Out, ANNOUNCE_ONLY_PREFIX)].as_slice(),
PostPolicy {
pk: -1,
list: -1,
announce_only: true,
subscription_only: false,
approval_needed: false,
open: false,
custom: false,
},
),
(
[(Queue::Out, APPROVAL_ONLY_PREFIX), (Queue::Deferred, None)].as_slice(),
PostPolicy {
pk: -1,
list: -1,
announce_only: false,
subscription_only: false,
approval_needed: true,
open: false,
custom: false,
},
),
] {
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let lists = db.lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0], foo_chat);
post_policy.list = foo_chat.pk();
let post_policy = db.set_list_post_policy(post_policy).unwrap();
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
let db = db.untrusted();
let post_bytes = b"From: Name <user@example.com>
To: <foo-chat@example.com>
Subject: This is a post
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
eT48L2h0bWw+
";
let envelope =
melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
for &(q, prefix) in q {
let q = db.queue(q).unwrap();
assert_eq!(q.len(), 1);
if let Some(prefix) = prefix {
assert_eq!(
q[0].comment.as_ref().and_then(|c| c.get(..prefix.len())),
Some(prefix)
);
}
}
}
}
#[test]
fn test_post_filters() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let mut post_policy = PostPolicy {
pk: -1,
list: -1,
announce_only: false,
subscription_only: false,
approval_needed: false,
open: true,
custom: false,
};
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
post_policy.list = foo_chat.pk();
db.add_subscription(
foo_chat.pk(),
ListSubscription {
pk: -1,
list: foo_chat.pk(),
address: "user@example.com".into(),
name: None,
account: None,
digest: false,
enabled: true,
verified: true,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: false,
},
)
.unwrap();
db.set_list_post_policy(post_policy).unwrap();
let post_bytes = b"From: Name <user@example.com>
To: <foo-chat@example.com>
Subject: This is a post
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
eT48L2h0bWw+
";
let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
let q = db.queue(Queue::Out).unwrap();
assert_eq!(&q[0].subject, "[foo-chat] This is a post");
db.delete_from_queue(Queue::Out, vec![]).unwrap();
{
let mut stmt = db
.connection
.prepare(
"INSERT INTO list_settings_json(name, list, value) \
VALUES('AddSubjectTagPrefixSettings', ?, ?) RETURNING *;",
)
.unwrap();
stmt.query_row(
rusqlite::params![
&foo_chat.pk(),
&json!({
"enabled": false
}),
],
|_| Ok(()),
)
.unwrap();
}
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
let q = db.queue(Queue::Out).unwrap();
assert_eq!(&q[0].subject, "This is a post");
}

View File

@ -1,236 +0,0 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail, Template};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
#[test]
fn test_template_replies() {
init_stderr_logging();
const SUB_BYTES: &[u8] = b"From: Name <user@example.com>
To: <foo-chat+subscribe@example.com>
Subject: subscribe
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
";
const UNSUB_BYTES: &[u8] = b"From: Name <user@example.com>
To: <foo-chat+request@example.com>
Subject: unsubscribe
Date: Thu, 29 Oct 2020 13:58:17 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
";
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let mut db = Connection::open_or_create_db(config).unwrap().trusted();
assert!(db.lists().unwrap().is_empty());
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let lists = db.lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0], foo_chat);
let post_policy = db
.set_list_post_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscription_only: true,
approval_needed: false,
open: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
let _templ_gen = db
.add_template(Template {
pk: -1,
name: Template::SUBSCRIPTION_CONFIRMATION.into(),
list: None,
subject: Some("You have subscribed to a list".into()),
headers_json: None,
body: "You have subscribed to a list".into(),
})
.unwrap();
/* create custom subscribe confirm template, and check that it is used in
* action */
let _templ = db
.add_template(Template {
pk: -1,
name: Template::SUBSCRIPTION_CONFIRMATION.into(),
list: Some(foo_chat.pk()),
subject: Some("You have subscribed to {{ list.name }}".into()),
headers_json: None,
body: "You have subscribed to {{ list.name }}".into(),
})
.unwrap();
let _all = db.fetch_templates().unwrap();
assert_eq!(&_all[0], &_templ_gen);
assert_eq!(&_all[1], &_templ);
assert_eq!(_all.len(), 2);
let sub_fn = |db: &mut Connection| {
let subenvelope =
melib::Envelope::from_bytes(SUB_BYTES, None).expect("Could not parse message");
db.post(&subenvelope, SUB_BYTES, /* dry_run */ false)
.unwrap();
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
};
let unsub_fn = |db: &mut Connection| {
let envelope =
melib::Envelope::from_bytes(UNSUB_BYTES, None).expect("Could not parse message");
db.post(&envelope, UNSUB_BYTES, /* dry_run */ false)
.unwrap();
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
};
/* subscribe first */
sub_fn(&mut db);
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 1);
let out = &out_queue[0];
let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
assert_eq!(
&out_env.from()[0].get_email(),
"foo-chat+request@example.com",
);
assert_eq!(
(
out_env.to()[0].get_display_name().as_deref(),
out_env.to()[0].get_email().as_str()
),
(Some("Name"), "user@example.com"),
);
assert_eq!(
&out.subject,
&format!("You have subscribed to {}", foo_chat.name)
);
/* then unsubscribe, remove custom template and subscribe again */
unsub_fn(&mut db);
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 2);
let mut _templ = _templ.into_inner();
let _templ2 = db
.remove_template(Template::SUBSCRIPTION_CONFIRMATION, Some(foo_chat.pk()))
.unwrap();
_templ.pk = _templ2.pk;
assert_eq!(_templ, _templ2);
/* now the first inserted template should be used: */
sub_fn(&mut db);
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 3);
let out = &out_queue[2];
let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
assert_eq!(
&out_env.from()[0].get_email(),
"foo-chat+request@example.com",
);
assert_eq!(
(
out_env.to()[0].get_display_name().as_deref(),
out_env.to()[0].get_email().as_str()
),
(Some("Name"), "user@example.com"),
);
assert_eq!(&out.subject, "You have subscribed to a list");
unsub_fn(&mut db);
let mut _templ_gen_2 = db
.remove_template(Template::SUBSCRIPTION_CONFIRMATION, None)
.unwrap();
_templ_gen_2.pk = _templ_gen.pk;
assert_eq!(_templ_gen_2, _templ_gen.into_inner());
/* now this template should be used: */
sub_fn(&mut db);
let out_queue = db.queue(Queue::Out).unwrap();
assert_eq!(out_queue.len(), 5);
let out = &out_queue[4];
let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
assert_eq!(
&out_env.from()[0].get_email(),
"foo-chat+request@example.com",
);
assert_eq!(
(
out_env.to()[0].get_display_name().as_deref(),
out_env.to()[0].get_email().as_str()
),
(Some("Name"), "user@example.com"),
);
assert_eq!(
&out.subject,
&format!(
"[{}] You have successfully subscribed to {}.",
foo_chat.id, foo_chat.name
)
);
}

View File

@ -1,25 +0,0 @@
Return-Path: <paaoejunp@example.com>
Delivered-To: john@example.com
Received: from violet.example.com
by violet.example.com with LMTP
id qBHcI7LKml9FxzIAYrQLqw
(envelope-from <paaoejunp@example.com>)
for <john@example.com>; Thu, 29 Oct 2020 13:59:14 +0000
Return-path: <paaoejunp@example.com>
Envelope-to: john@example.com
Delivery-date: Thu, 29 Oct 2020 13:59:14 +0000
From: Cardholder Name <paaoejunp@example.com>
To: <foo-chat@example.com>
Subject: thankful that I had the chance to written report, that I could learn
and let alone the chance $4454.32
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <abcdefgh@sator.example.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
eT48L2h0bWw+

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""
Example taken from https://jcristharif.com/msgspec/jsonschema.html
"""
import msgspec
from msgspec import Struct, Meta
from typing import Annotated, Optional
Template = Annotated[
str,
Meta(
pattern=".+[{]msg-id[}].*",
description="""Template for \
`Archived-At` header value, as described in RFC 5064 "The Archived-At \
Message Header Field". The template receives only one string variable \
with the value of the mailing list post `Message-ID` header.
For example, if:
- the template is `http://www.example.com/mid/{msg-id}`
- the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`
The full header will be generated as:
`Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>
Note: Surrounding carets in the `Message-ID` value are not required. If \
you wish to preserve them in the URL, set option `preserve-carets` to \
true.""",
title="Jinja template for header value",
examples=[
"https://www.example.com/{msg-id}",
"https://www.example.com/{msg-id}.html",
],
),
]
PreserveCarets = Annotated[
bool, Meta(title="Preserve carets of `Message-ID` in generated value")
]
class ArchivedAtLinkSettings(Struct):
"""Settings for ArchivedAtLink message filter"""
template: Template
preserve_carets: PreserveCarets = False
schema = {"$schema": "http://json-schema.org/draft-07/schema"}
schema.update(msgspec.json.schema(ArchivedAtLinkSettings))
print(msgspec.json.format(msgspec.json.encode(schema)).decode("utf-8"))

View File

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" width="96" height="20" role="img" aria-label="coverage: 58%">
<title>coverage: 58%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="96" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="61" height="20" fill="#555"/>
<rect x="61" width="35" height="20" fill="#e05d44"/>
<rect width="96" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
<text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text>
<text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text>
<text aria-hidden="true" x="775" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="250">58%</text>
<text x="775" y="140" transform="scale(.1)" fill="#fff" textLength="250">58%</text>
</g>
<script xmlns=""/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" width="96" height="20" role="img" aria-label="coverage: 58%">
<title>coverage: 58%</title>
<g shape-rendering="crispEdges">
<rect width="61" height="20" fill="#555"/>
<rect x="61" width="35" height="20" fill="#e05d44"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
<text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text>
<text x="775" y="140" transform="scale(.1)" fill="#fff" textLength="250">58%</text>
</g>
<script xmlns=""/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

Some files were not shown because too many files have changed in this diff Show More