Compare commits
No commits in common. "main" and "gh-pages" have entirely different histories.
|
@ -1,2 +0,0 @@
|
|||
[doc.extern-map.registries]
|
||||
crates-io = "https://docs.rs/"
|
|
@ -1 +0,0 @@
|
|||
github: [epilys]
|
|
@ -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>
|
|
@ -1,5 +0,0 @@
|
|||
ignore-not-existing: true
|
||||
branch: true
|
||||
output-type: html
|
||||
binary-path: ./target/debug/
|
||||
output-path: ./coverage/
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
102
CONTRIBUTING.md
102
CONTRIBUTING.md
|
@ -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>
|
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
|
@ -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"
|
46
Makefile
46
Makefile
|
@ -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
329
README.md
|
@ -1,329 +0,0 @@
|
|||
# mailpot - mailing list manager
|
||||
|
||||
[![Latest Version]][crates.io] [![Coverage]][grcov-rport] [![docs.rs]][rustdoc] ![Top Language] ![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>(())
|
||||
```
|
|
@ -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"
|
|
@ -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"
|
||||
```
|
|
@ -1 +0,0 @@
|
|||
../rustfmt.toml
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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;
|
|
@ -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(())
|
||||
}
|
|
@ -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 %}
|
|
@ -1,8 +0,0 @@
|
|||
<footer>
|
||||
<hr />
|
||||
<p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
|
@ -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" %}
|
|
@ -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">👤 <span class="from">{{ post.address }}</span> 📆 <span class="date">{{ post.datetime }}</span></span>
|
||||
<span class="metadata">🪪 <span class="message-id">{{ post.message_id }}</span></span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -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" %}
|
|
@ -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>
|
|
@ -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" %}
|
|
@ -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>,
|
||||
}
|
|
@ -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" }
|
|
@ -1,5 +0,0 @@
|
|||
# mailpot-cli
|
||||
|
||||
```shell
|
||||
cargo run --bin mpot -- help
|
||||
```
|
524
cli/build.rs
524
cli/build.rs
|
@ -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)
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
../rustfmt.toml
|
571
cli/src/args.rs
571
cli/src/args.rs
|
@ -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()
|
||||
}
|
||||
}
|
1093
cli/src/commands.rs
1093
cli/src/commands.rs
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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};
|
262
cli/src/lints.rs
262
cli/src/lints.rs
|
@ -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(())
|
||||
}
|
221
cli/src/main.rs
221
cli/src/main.rs
|
@ -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(())
|
||||
}
|
|
@ -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",
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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" }
|
|
@ -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
|
||||
```
|
|
@ -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
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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}.")
|
|
@ -1,2 +0,0 @@
|
|||
PRAGMA foreign_keys=ON;
|
||||
ALTER TABLE templates RENAME TO template;
|
|
@ -1,2 +0,0 @@
|
|||
PRAGMA foreign_keys=ON;
|
||||
ALTER TABLE template RENAME TO templates;
|
|
@ -1,2 +0,0 @@
|
|||
PRAGMA foreign_keys=ON;
|
||||
ALTER TABLE list ADD COLUMN topics JSON NOT NULL CHECK (json_type(topics) == 'array') DEFAULT '[]';
|
|
@ -1,2 +0,0 @@
|
|||
PRAGMA foreign_keys=ON;
|
||||
ALTER TABLE list DROP COLUMN topics;
|
|
@ -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;
|
|
@ -1,4 +0,0 @@
|
|||
PRAGMA foreign_keys=ON;
|
||||
|
||||
DROP TRIGGER sort_topics_update_trigger;
|
||||
DROP TRIGGER sort_topics_new_trigger;
|
|
@ -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;
|
|
@ -1,2 +0,0 @@
|
|||
DROP TABLE settings_json_schema;
|
||||
DROP TABLE list_settings_json;
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}');
|
|
@ -1 +0,0 @@
|
|||
DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}');
|
|
@ -1 +0,0 @@
|
|||
DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';
|
|
@ -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!#$&-^_]+$"
|
||||
}
|
||||
}
|
||||
}');
|
|
@ -1 +0,0 @@
|
|||
DELETE FROM settings_json_schema WHERE id = 'MimeRejectSettings';
|
|
@ -1 +0,0 @@
|
|||
../rustfmt.toml
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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!#$&-^_]+$"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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,
|
||||
# };
|
|
@ -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()))
|
||||
}
|
||||
}
|
259
core/src/lib.rs
259
core/src/lib.rs
|
@ -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'%');
|
181
core/src/mail.rs
181
core/src/mail.rs
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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>>()?)
|
||||
}
|
||||
}
|
|
@ -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';"##),]
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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>,
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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(())
|
||||
}
|
||||
}
|
|
@ -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![]);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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.",
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
|
@ -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())
|
||||
)"#
|
||||
)
|
||||
);
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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-----
|
|
@ -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
|
|
@ -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");
|
||||
}
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
|
@ -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+
|
|
@ -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"))
|
|
@ -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 |
|
@ -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
Loading…
Reference in New Issue