Compare commits

..

1 Commits

Author SHA1 Message Date
Manos Pitsidianakis 3906a08037
Document entire `core` create, add CI, tests 2023-04-03 20:39:27 +03:00
200 changed files with 5741 additions and 28091 deletions

View File

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

View File

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

5
.github/grcov.yml vendored
View File

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

View File

@ -3,7 +3,6 @@ name: Build release binary
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
on:
workflow_dispatch:
@ -27,24 +26,6 @@ jobs:
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
@ -82,15 +63,13 @@ jobs:
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/
cargo build --release --bin mpot --bin mpot-gen -p mailpot-cli -p mpot-archives
mv target/*/release/mailpot target/mailpot || true
mv target/release/mailpot target/mailpot || true
- name: Upload Artifacts
uses: actions/upload-artifact@v2
with:
name: ${{ matrix.artifact_name }}
path: artifacts
path: target/mailpot
if-no-files-found: error
retention-days: 7

View File

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

View File

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

View File

@ -3,7 +3,6 @@ name: Tests
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
on:
workflow_dispatch:
@ -31,24 +30,6 @@ jobs:
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
@ -95,20 +76,16 @@ jobs:
- 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
cargo test --all --no-fail-fast --all-features
- name: cargo-sort
if: success() || failure()
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 sort --check
- name: rustfmt
if: success() || failure()
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 fmt --check --all
- name: clippy
if: success() || failure()
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 clippy --no-deps --all-features --all --tests --examples --benches --bins
- name: rustdoc
if: success() || failure()
run: |
make rustdoc

View File

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

View File

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

3428
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,10 @@
[workspace]
resolver = "2"
members = [
"mailpot",
"mailpot-archives",
"mailpot-cli",
"mailpot-http",
"mailpot-tests",
"mailpot-web",
"archive-http",
"cli",
"core",
"rest-http",
]
[profile.release]
lto = "fat"
opt-level = "z"
codegen-units = 1
split-debuginfo = "unpacked"
#[patch.crates-io]
#structopt-derive = { git = "https://github.com/epilys/structopt-derive-manpage" }

View File

@ -1,46 +1,12 @@
.POSIX:
.SUFFIXES:
CARGOBIN = cargo
CARGOSORTBIN = cargo-sort
DJHTMLBIN = djhtml
BLACKBIN = black
PRINTF = /usr/bin/printf
HTML_FILES := $(shell find mailpot-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
cargo check --all
.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"
cargo fmt --all
cargo sort -w || true
.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
cargo clippy --all

150
README.md
View File

@ -1,42 +1,20 @@
# mailpot - mailing list manager
# mailpot - WIP mailing list manager
[![Latest Version]][crates.io]&nbsp;[![Coverage]][grcov-rport]&nbsp;[![docs.rs]][rustdoc]&nbsp;![Top Language]&nbsp;![License]
[Latest Version]: https://img.shields.io/crates/v/mailpot.svg?color=white
[crates.io]: https://crates.io/crates/mailpot
[Top Language]: https://img.shields.io/github/languages/top/meli/mailpot?color=white&logo=rust&logoColor=black
[License]: https://img.shields.io/github/license/meli/mailpot?color=white
[docs.rs]: https://img.shields.io/docsrs/mailpot?color=white
[rustdoc]: https://meli.github.io/mailpot/docs/mailpot/
[Coverage]: https://img.shields.io/endpoint?color=white&url=https://meli.github.io/mailpot/coverage/coverage.json
[grcov-rport]: https://meli.github.io/mailpot/coverage/
- Official hosted instance of `mailpot-web` crate: <https://lists.meli.delivery/>
- Rendered rustdoc: <https://meli.github.io/mailpot/docs/mailpot/>
- CLI manpage: [`mpot.1`](./docs/mpot.1) [Rendered](https://git.meli.delivery/meli/mailpot/src/branch/main/docs/mpot.1)
| Interested in contributing? Consult [`CONTRIBUTING.md`](./CONTRIBUTING.md). |
| --- |
## crates:
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
## Project goals
- 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 Rust API as a library
- extensible through HTTP REST API as an HTTP server, with webhooks
- basic management through CLI
- optional lightweight web archiver
- useful for both newsletters, discussions, article comments
## Initial setup
@ -47,8 +25,8 @@ $ 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.
$ cargo run --bin mpot -- -c "$MPOT_CONFIG" db-location
/home/user/.local/share/mailpot/mpot.db
```
This creates the database file in the configuration file as if you executed the following:
@ -61,73 +39,27 @@ $ sqlite3 /home/user/.local/share/mailpot/mpot.db < ./core/src/schema.sql
```text
% mpot help
GNU Affero version 3 or later <https://www.gnu.org/licenses/>
mailpot 0.1.0
mini mailing list manager
Tool for mailpot mailing list management.
USAGE:
mpot [FLAGS] [OPTIONS] <SUBCOMMAND>
Usage: mpot [OPTIONS] <COMMAND>
FLAGS:
-d, --debug Activate debug mode
-h, --help Prints help information
-V, --version Prints version information
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:
-c, --config <config> Set config file
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
SUBCOMMANDS:
create-list Create new list
db-location Prints database filesystem location
help Prints this message or the help of the given subcommand(s)
list Mailing list management
list-lists Lists all registered mailing lists
post Post message from STDIN to list
```
### Receiving mail
@ -194,8 +126,8 @@ TRACE - Received envelope to post: Envelope {
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 {
TRACE - List members [
ListMembership {
list: 2,
address: "exxxxx@localhost",
name: None,
@ -211,9 +143,9 @@ 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 - examining member ListMembership { 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 - member is submitter
TRACE - Member gets copy
TRACE - result Ok(
Post {
list: MailingList {
@ -228,7 +160,7 @@ TRACE - result Ok(
display_name: "Mxxxx Pxxxxxxxxxxxx",
address_spec: "exxxxx@localhost",
},
subscriptions: 1,
members: 1,
bytes: 851,
policy: None,
to: [
@ -263,7 +195,6 @@ 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();
@ -274,26 +205,25 @@ let list_pk = db.create_list(MailingList {
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?.pk;
db.set_list_post_policy(
db.set_list_policy(
PostPolicy {
pk: 0,
list: list_pk,
announce_only: false,
subscription_only: true,
subscriber_only: true,
approval_needed: false,
open: false,
no_subscriptions: false,
custom: false,
},
)?;
// Drop privileges; we can only process new e-mail and modify subscriptions from now on.
let mut db = db.untrusted();
// Drop privileges; we can only process new e-mail and modify memberships from now on.
let db = db.untrusted();
assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
assert_eq!(db.list_members(list_pk)?.len(), 0);
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
// Process a subscription request e-mail
@ -307,7 +237,7 @@ 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_members(list_pk)?.len(), 1);
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
// Process a post
@ -323,7 +253,7 @@ 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_members(list_pk)?.len(), 1);
assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
# Ok::<(), Error>(())
```

View File

@ -0,0 +1,37 @@
[package]
name = "mpot-archives"
version = "0.1.0"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2018"
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"
required-features = ["warp"]
[[bin]]
name = "mpot-gen"
path = "src/gen.rs"
[dependencies]
chrono = { version = "^0.4", optional = true }
lazy_static = "*"
mailpot = { version = "0.1.0", path = "../core" }
minijinja = { version = "0.31.0", features = ["source", ], optional = true }
percent-encoding = { version = "^2.1", optional = true }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
tokio = { version = "1", features = ["full"], optional = true }
warp = { version = "^0.3", optional = true }
[features]
default = ["gen"]
gen = ["dep:chrono", "dep:minijinja"]
warp = ["dep:percent-encoding", "dep:tokio", "dep:warp"]

View File

@ -9,8 +9,8 @@
// 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 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,
@ -25,14 +25,14 @@ 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.
/// 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;
/// use calendarize::calendarize;
///
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
/// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
@ -50,8 +50,8 @@ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
/// 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.
/// 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.
@ -60,7 +60,7 @@ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
/// # Examples
/// ```
/// use chrono::*;
/// use mailpot_archives::cal::calendarize_with_offset;
/// use calendarize::calendarize_with_offset;
///
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
/// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]

View File

@ -17,11 +17,186 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{fs::OpenOptions, io::Write};
extern crate mailpot;
use chrono::Datelike;
use mailpot::*;
use mailpot_archives::utils::*;
use minijinja::value::Value;
mod cal;
pub use mailpot::models::*;
pub use mailpot::*;
use std::borrow::Cow;
use std::fs::OpenOptions;
use std::io::Write;
use minijinja::{Environment, Error, Source, State};
use minijinja::value::{Object, Value};
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
};
}
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 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,
archive_url,
},
_,
) = val.clone();
Self {
pk,
name,
id,
address,
description,
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 {
"subscribe_mailto" => Ok(Value::from_serializable(&self.inner.subscribe_mailto())),
"unsubscribe_mailto" => Ok(Value::from_serializable(&self.inner.unsubscribe_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)),
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
_ => None,
}
}
fn static_fields(&self) -> Option<&'static [&'static str]> {
Some(&["pk", "name", "id", "address", "description", "archive_url"][..])
}
}
fn calendarize(_state: &State, args: Value, hists: Value) -> std::result::Result<Value, Error> {
use chrono::Month;
use std::convert::TryFrom;
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 => 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>,
}
fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().collect::<Vec<_>>();
@ -98,8 +273,8 @@ fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
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 list = db.list(list.pk)?;
let post_policy = db.list_policy(list.pk)?;
let months = db.months(list.pk)?;
let posts = db.list_posts(list.pk, None)?;
let mut hist = months

View File

@ -0,0 +1,162 @@
/*
* 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 mailpot;
pub use mailpot::models::*;
pub use mailpot::*;
use minijinja::{Environment, Source};
use percent_encoding::percent_decode_str;
use warp::Filter;
lazy_static::lazy_static! {
pub static ref TEMPLATES: Environment<'static> = {
let mut env = Environment::new();
env.set_source(Source::from_path("src/templates/"));
env
};
}
#[tokio::main]
async fn main() {
let config_path = std::env::args()
.nth(1)
.expect("Expected configuration file path as first argument.");
let conf = Configuration::from_file(config_path).unwrap();
let conf1 = conf.clone();
let list_handler = warp::path!("lists" / i64).map(move |list_pk: i64| {
let db = Connection::open_db(conf1.clone()).unwrap();
let list = db.list(list_pk).unwrap();
let months = db.months(list_pk).unwrap();
let posts = db
.list_posts(list_pk, None)
.unwrap()
.into_iter()
.map(|post| {
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.expect("Could not parse mail");
minijinja::context! {
pk => post.pk,
list => post.list,
subject => envelope.subject(),
address=> post.address,
message_id => post.message_id,
message => post.message,
timestamp => post.timestamp,
datetime => post.datetime,
}
})
.collect::<Vec<_>>();
let context = minijinja::context! {
title=> &list.name,
list=> &list,
months=> &months,
posts=> posts,
body=>&list.description.clone().unwrap_or_default(),
};
Ok(warp::reply::html(
TEMPLATES
.get_template("list.html")
.unwrap()
.render(context)
.unwrap_or_else(|err| err.to_string()),
))
});
let conf2 = conf.clone();
let post_handler =
warp::path!("list" / i64 / String).map(move |list_pk: i64, message_id: String| {
let message_id = percent_decode_str(&message_id).decode_utf8().unwrap();
let db = Connection::open_db(conf2.clone()).unwrap();
let list = db.list(list_pk).unwrap();
let posts = db.list_posts(list_pk, None).unwrap();
let post = posts
.iter()
.find(|p| message_id.contains(&p.message_id))
.unwrap();
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
.expect("Could not parse mail");
let body = envelope.body_bytes(post.message.as_slice());
let body_text = body.text();
let context = minijinja::context !{
title => &list.name,
list => &list,
post => &post,
posts => &posts,
body => &body_text,
from => &envelope.field_from_to_string(),
date => &envelope.date_as_str(),
to => &envelope.field_to_to_string(),
subject => &envelope.subject(),
in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string()),
references => &envelope .references() .into_iter() .map(|m| m.to_string()) .collect::<Vec<String>>(),
};
Ok(warp::reply::html(
TEMPLATES
.get_template("post.html")
.unwrap()
.render(context)
.unwrap_or_else(|err| err.to_string()),
))
});
let conf3 = conf.clone();
let index_handler = warp::path::end().map(move || {
let db = Connection::open_db(conf3.clone()).unwrap();
let lists_values = db.lists().unwrap();
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,
list => &list,
posts => &posts,
months => &months,
body => &list.description.as_deref().unwrap_or_default(),
}
})
.collect::<Vec<_>>();
let context = minijinja::context! {
title => "mailing list archive",
description => "",
lists => &lists,
};
Ok(warp::reply::html(
TEMPLATES
.get_template("lists.html")
.unwrap()
.render(context)
.unwrap_or_else(|err| err.to_string()),
))
});
let routes = warp::get()
.and(index_handler)
.or(list_handler)
.or(post_handler);
// Note that composing filters for many routes may increase compile times (because it uses a lot of generics).
// If you wish to use dynamic dispatch instead and speed up compile times while
// making it slightly slower at runtime, you can use Filter::boxed().
eprintln!("Running at http://127.0.0.1:3030");
warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}

View File

@ -7,31 +7,31 @@
{% 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 %}
{% set subscribe_mailto=list.subscribe_mailto() %}
{% if subscribe_mailto %}
{% if subscribe_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>
<a href="mailto:{{ subscribe_mailto.address|safe }}?subject={{ subscribe_mailto.subject|safe }}"><code>{{ subscribe_mailto.address }}</code></a> with the following subject: <code>{{ subscribe_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
<a href="mailto:{{ subscribe_mailto.address|safe }}"><code>{{ subscribe_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 %}
{% set unsubscribe_mailto=list.unsubscribe_mailto() %}
{% if unsubscribe_mailto %}
<h2 id="unsubscribe">Unsubscribe</h2>
{% if unsubscription_mailto.subject %}
{% if unsubscribe_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>
<a href="mailto:{{ unsubscribe_mailto.address|safe }}?subject={{ unsubscribe_mailto.subject|safe }}"><code>{{ unsubscribe_mailto.address }}</code></a> with the following subject: <code>{{unsubscribe_mailto.subject}}</code>
</p>
{% else %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
<a href="mailto:{{ unsubscribe_mailto.address|safe }}"><code>{{ unsubscribe_mailto.address }}</code></a>
</p>
{% endif %}
{% endif %}
@ -40,8 +40,8 @@
<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>
{% elif post_policy.subscriber_only %}
<p>List is <em>subscriber-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>

22
cli/Cargo.toml 100644
View File

@ -0,0 +1,22 @@
[package]
name = "mailpot-cli"
version = "0.1.0"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2018"
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"
[dependencies]
log = "0.4"
mailpot = { version = "0.1.0", path = "../core" }
stderrlog = "^0.5"
structopt = "0.3.16"

41
cli/build.rs 100644
View File

@ -0,0 +1,41 @@
/*
* 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::Write;
//use std::process::Command;
fn main() {
//println!("cargo:rerun-if-changed=../docs/command.mdoc");
//println!("cargo:rerun-if-changed=../docs/list.mdoc");
//println!("cargo:rerun-if-changed=../docs/error_queue.mdoc");
//println!("cargo:rerun-if-changed=../docs/main.mdoc");
//println!("cargo:rerun-if-changed=../docs/header.mdoc");
//println!("cargo:rerun-if-changed=../docs/footer.mdoc");
//println!("cargo:rerun-if-changed=../docs/mailpot.1.m4");
//println!("cargo:rerun-if-changed=./src/main.rs");
//println!("build running");
//std::env::set_current_dir("..").expect("could not chdir('..')");
//let output = Command::new("m4")
// .arg("./docs/mailpot.1.m4")
// .output()
// .unwrap();
//let mut file = std::fs::File::create("./docs/mailpot.1").unwrap();
//file.write_all(&output.stdout).unwrap();
}

707
cli/src/main.rs 100644
View File

@ -0,0 +1,707 @@
/*
* 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 log;
extern crate mailpot;
extern crate stderrlog;
pub use mailpot::mail::*;
pub use mailpot::models::changesets::*;
pub use mailpot::models::*;
pub use mailpot::*;
use std::path::PathBuf;
use structopt::StructOpt;
macro_rules! list {
($db:ident, $list_id:expr) => {{
$db.list_by_id(&$list_id)?.or_else(|| {
$list_id
.parse::<i64>()
.ok()
.map(|pk| $db.list(pk).ok())
.flatten()
})
}};
}
#[derive(Debug, StructOpt)]
#[structopt(
name = "mailpot",
about = "mini mailing list manager",
author = "Manos Pitsidianakis <epilys@nessuent.xyz>",
//manpage = "docs/main.mdoc",
//manpage_header = "docs/header.mdoc",
//manpage_footer = "docs/footer.mdoc"
)]
struct Opt {
/// Activate debug mode
#[structopt(short, long)]
debug: bool,
/// Set config file
#[structopt(short, long, parse(from_os_str))]
config: PathBuf,
#[structopt(flatten)]
cmd: Command,
/// Silence all output
#[structopt(short = "q", long = "quiet")]
quiet: bool,
/// Verbose mode (-v, -vv, -vvv, etc)
#[structopt(short = "v", long = "verbose", parse(from_occurrences))]
verbose: usize,
/// Timestamp (sec, ms, ns, none)
#[structopt(short = "t", long = "timestamp")]
ts: Option<stderrlog::Timestamp>,
}
#[derive(Debug, StructOpt)]
//#[structopt(manpage = "docs/command.mdoc")]
enum Command {
/// Prints a sample config file to STDOUT
SampleConfig,
///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,
#[structopt(subcommand)]
cmd: ListCommand,
},
///Create new list
CreateList {
///List name
#[structopt(long)]
name: String,
///List ID
#[structopt(long)]
id: String,
///List e-mail address
#[structopt(long)]
address: String,
///List description
#[structopt(long)]
description: Option<String>,
///List archive URL
#[structopt(long)]
archive_url: Option<String>,
},
///Post message from STDIN to list
Post {
#[structopt(long)]
dry_run: bool,
},
/// Mail that has not been handled properly end up in the error queue.
ErrorQueue {
#[structopt(subcommand)]
cmd: ErrorQueueCommand,
},
/// Import a maildir folder into an existing list.
ImportMaildir {
///Selects mailing list to operate on
list_id: String,
#[structopt(long, parse(from_os_str))]
maildir_path: PathBuf,
},
}
#[derive(Debug, StructOpt)]
//#[structopt(manpage = "docs/error_queue.mdoc")]
enum ErrorQueueCommand {
/// List.
List,
/// Print entry in RFC5322 or JSON format.
Print {
/// index of entry.
#[structopt(long)]
index: Vec<i64>,
/// JSON format.
#[structopt(long)]
json: bool,
},
/// Delete entry and print it in stdout.
Delete {
/// index of entry.
#[structopt(long)]
index: Vec<i64>,
/// Do not print in stdout.
#[structopt(long)]
quiet: bool,
},
}
#[derive(Debug, StructOpt)]
//#[structopt(manpage = "docs/list.mdoc")]
enum ListCommand {
/// List members of list.
Members,
/// Add member to list.
AddMember {
/// E-mail address
#[structopt(long)]
address: String,
/// Name
#[structopt(long)]
name: Option<String>,
/// Send messages as digest?
#[structopt(long)]
digest: bool,
/// Hide message from list when posting?
#[structopt(long)]
hide_address: bool,
/// Hide message from list when posting?
#[structopt(long)]
/// Receive confirmation email when posting?
receive_confirmation: Option<bool>,
#[structopt(long)]
/// Receive posts from list even if address exists in To or Cc header?
receive_duplicates: Option<bool>,
#[structopt(long)]
/// Receive own posts from list?
receive_own_posts: Option<bool>,
#[structopt(long)]
/// Is subscription enabled?
enabled: Option<bool>,
},
/// Remove member from list.
RemoveMember {
#[structopt(long)]
/// E-mail address
address: String,
},
/// Update membership info.
UpdateMembership {
address: String,
name: Option<String>,
digest: Option<bool>,
hide_address: Option<bool>,
receive_duplicates: Option<bool>,
receive_own_posts: Option<bool>,
receive_confirmation: Option<bool>,
enabled: Option<bool>,
},
/// Add policy to list.
AddPolicy {
#[structopt(long)]
announce_only: bool,
#[structopt(long)]
subscriber_only: bool,
#[structopt(long)]
approval_needed: bool,
#[structopt(long)]
no_subscriptions: bool,
#[structopt(long)]
custom: bool,
},
RemovePolicy {
#[structopt(long)]
pk: i64,
},
/// Add list owner to list.
AddListOwner {
#[structopt(long)]
address: String,
#[structopt(long)]
name: Option<String>,
},
RemoveListOwner {
#[structopt(long)]
pk: i64,
},
/// Alias for update-membership --enabled true
EnableMembership { address: String },
/// Alias for update-membership --enabled false
DisableMembership { address: String },
/// Update mailing list details.
Update {
name: Option<String>,
id: Option<String>,
address: Option<String>,
description: Option<String>,
archive_url: Option<String>,
},
/// Show mailing list health status.
Health,
/// Show mailing list info.
Info,
}
fn run_app(opt: Opt) -> Result<()> {
if opt.debug {
println!("DEBUG: {:?}", &opt);
}
if let Command::SampleConfig = opt.cmd {
println!("{}", Configuration::new("/path/to/sqlite.db").to_toml());
return Ok(());
};
let config = Configuration::from_file(opt.config.as_path())?;
use Command::*;
let mut db = Connection::open_or_create_db(config)?;
match opt.cmd {
SampleConfig => {}
DumpDatabase => {
let lists = db.lists()?;
let mut stdout = std::io::stdout();
serde_json::to_writer_pretty(&mut stdout, &lists)?;
for l in &lists {
serde_json::to_writer_pretty(&mut stdout, &db.list_members(l.pk)?)?;
}
}
ListLists => {
let lists = db.lists()?;
if lists.is_empty() {
println!("No lists found.");
} else {
for l in lists {
println!("- {} {:?}", l.id, l);
let list_owners = db.list_owners(l.pk)?;
if list_owners.is_empty() {
println!("\tList owners: None");
} else {
println!("\tList owners:");
for o in list_owners {
println!("\t- {}", o);
}
}
if let Some(s) = db.list_policy(l.pk)? {
println!("\tList policy: {}", s);
} else {
println!("\tList policy: None");
}
println!();
}
}
}
List { list_id, cmd } => {
let list = match list!(db, list_id) {
Some(v) => v,
None => {
return Err(format!("No list with id or pk {} was found", list_id).into());
}
};
use ListCommand::*;
match cmd {
Members => {
let members = db.list_members(list.pk)?;
if members.is_empty() {
println!("No members found.");
} else {
println!("Members of list {}", list.id);
for l in members {
println!("- {}", &l);
}
}
}
AddMember {
address,
name,
digest,
hide_address,
receive_confirmation,
receive_duplicates,
receive_own_posts,
enabled,
} => {
db.add_member(
list.pk,
ListMembership {
pk: 0,
list: list.pk,
name,
address,
digest,
hide_address,
receive_confirmation: receive_confirmation.unwrap_or(true),
receive_duplicates: receive_duplicates.unwrap_or(true),
receive_own_posts: receive_own_posts.unwrap_or(false),
enabled: enabled.unwrap_or(true),
},
)?;
}
RemoveMember { address } => {
loop {
println!(
"Are you sure you want to remove membership of {} from list {}? [Yy/n]",
address, list
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
break;
} else if input.trim() == "n" {
return Ok(());
}
}
db.remove_membership(list.pk, &address)?;
}
Health => {
println!("{} health:", list);
let list_owners = db.list_owners(list.pk)?;
let list_policy = db.list_policy(list.pk)?;
if list_owners.is_empty() {
println!("\tList has no owners: you should add at least one.");
} else {
for owner in list_owners {
println!("\tList owner: {}.", owner);
}
}
if let Some(list_policy) = list_policy {
println!("\tList has post policy: {}.", list_policy);
} else {
println!("\tList has no post policy: you should add one.");
}
}
Info => {
println!("{} info:", list);
let list_owners = db.list_owners(list.pk)?;
let list_policy = db.list_policy(list.pk)?;
let members = db.list_members(list.pk)?;
if members.is_empty() {
println!("No members.");
} else if members.len() == 1 {
println!("1 member.");
} else {
println!("{} members.", members.len());
}
if list_owners.is_empty() {
println!("List owners: None");
} else {
println!("List owners:");
for o in list_owners {
println!("\t- {}", o);
}
}
if let Some(s) = list_policy {
println!("List policy: {}", s);
} else {
println!("List policy: None");
}
}
UpdateMembership {
address,
name,
digest,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
enabled,
} => {
let name = if name
.as_ref()
.map(|s: &String| s.is_empty())
.unwrap_or(false)
{
None
} else {
Some(name)
};
let changeset = ListMembershipChangeset {
list: list.pk,
address,
name,
digest,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
enabled,
};
db.update_member(changeset)?;
}
AddPolicy {
announce_only,
subscriber_only,
approval_needed,
no_subscriptions,
custom,
} => {
let policy = PostPolicy {
pk: 0,
list: list.pk,
announce_only,
subscriber_only,
approval_needed,
no_subscriptions,
custom,
};
let new_val = db.set_list_policy(policy)?;
println!("Added new policy with pk = {}", new_val.pk());
}
RemovePolicy { pk } => {
db.remove_list_policy(list.pk, pk)?;
println!("Removed policy with pk = {}", pk);
}
AddListOwner { address, name } => {
let list_owner = ListOwner {
pk: 0,
list: list.pk,
address,
name,
};
let new_val = db.add_list_owner(list_owner)?;
println!("Added new list owner {}", new_val);
}
RemoveListOwner { pk } => {
db.remove_list_owner(list.pk, pk)?;
println!("Removed list owner with pk = {}", pk);
}
EnableMembership { address } => {
let changeset = ListMembershipChangeset {
list: list.pk,
address,
name: None,
digest: None,
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
enabled: Some(true),
};
db.update_member(changeset)?;
}
DisableMembership { address } => {
let changeset = ListMembershipChangeset {
list: list.pk,
address,
name: None,
digest: None,
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
enabled: Some(false),
};
db.update_member(changeset)?;
}
Update {
name,
id,
address,
description,
archive_url,
} => {
let description = if description
.as_ref()
.map(|s: &String| s.is_empty())
.unwrap_or(false)
{
None
} else {
Some(description)
};
let archive_url = if archive_url
.as_ref()
.map(|s: &String| s.is_empty())
.unwrap_or(false)
{
None
} else {
Some(archive_url)
};
let changeset = MailingListChangeset {
pk: list.pk,
name,
id,
address,
description,
archive_url,
};
db.update_list(changeset)?;
}
}
}
CreateList {
name,
id,
address,
description,
archive_url,
} => {
let new = db.create_list(MailingList {
pk: 0,
name,
id,
description,
address,
archive_url,
})?;
log::trace!("created new list {:#?}", new);
if !opt.quiet {
println!(
"Created new list {:?} with primary key {}",
new.id,
new.pk()
);
}
}
Post { dry_run } => {
if opt.debug {
println!("post dry_run{:?}", dry_run);
}
use melib::Envelope;
use std::io::Read;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
match Envelope::from_bytes(input.as_bytes(), None) {
Ok(env) => {
if opt.debug {
eprintln!("{:?}", &env);
}
db.post(&env, input.as_bytes(), dry_run)?;
}
Err(err) if input.trim().is_empty() => {
eprintln!("Empty input, abort.");
return Err(err.into());
}
Err(err) => {
eprintln!("Could not parse message: {}", err);
let p = db.conf().save_message(input)?;
eprintln!("Message saved at {}", p.display());
return Err(err.into());
}
}
}
ErrorQueue { cmd } => match cmd {
ErrorQueueCommand::List => {
let errors = db.error_queue()?;
if errors.is_empty() {
println!("Error queue is empty.");
} else {
for e in errors {
println!(
"- {} {} {} {} {}",
e["pk"],
e["datetime"],
e["from_address"],
e["to_address"],
e["subject"]
);
}
}
}
ErrorQueueCommand::Print { index, json } => {
let mut errors = db.error_queue()?;
if !index.is_empty() {
errors.retain(|el| index.contains(&el.pk()));
}
if errors.is_empty() {
println!("Error queue is empty.");
} else {
for e in errors {
if json {
println!("{:#}", e);
} else {
println!("{}", e["message"]);
}
}
}
}
ErrorQueueCommand::Delete { index, quiet } => {
let mut errors = db.error_queue()?;
if !index.is_empty() {
errors.retain(|el| index.contains(&el.pk()));
}
if errors.is_empty() {
if !quiet {
println!("Error queue is empty.");
}
} else {
if !quiet {
println!("Deleting error queue elements {:?}", &index);
}
db.delete_from_error_queue(index)?;
if !quiet {
for e in errors {
println!("{}", e["message"]);
}
}
}
}
},
ImportMaildir {
list_id,
mut maildir_path,
} => {
let list = match list!(db, list_id) {
Some(v) => v,
None => {
return Err(format!("No list with id or pk {} was found", list_id).into());
}
};
use melib::backends::maildir::MaildirPathTrait;
use melib::{Envelope, EnvelopeHash};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::Read;
if !maildir_path.is_absolute() {
maildir_path = std::env::current_dir()
.expect("could not detect current directory")
.join(&maildir_path);
}
fn get_file_hash(file: &std::path::Path) -> EnvelopeHash {
let mut hasher = DefaultHasher::default();
file.hash(&mut hasher);
EnvelopeHash(hasher.finish())
}
let mut buf = Vec::with_capacity(4096);
let files =
melib::backends::maildir::MaildirType::list_mail_in_maildir_fs(maildir_path, true)?;
let mut ctr = 0;
for file in files {
let hash = get_file_hash(&file);
let mut reader = std::io::BufReader::new(std::fs::File::open(&file)?);
buf.clear();
reader.read_to_end(&mut buf)?;
if let Ok(mut env) = Envelope::from_bytes(buf.as_slice(), Some(file.flags())) {
env.set_hash(hash);
db.insert_post(list.pk, &buf, &env)?;
ctr += 1;
}
}
println!("Inserted {} posts to {}.", ctr, list_id);
}
}
Ok(())
}
fn main() -> std::result::Result<(), i32> {
let opt = Opt::from_args();
stderrlog::new()
.module(module_path!())
.module("mailpot")
.quiet(opt.quiet)
.verbosity(opt.verbose)
.timestamp(opt.ts.unwrap_or(stderrlog::Timestamp::Off))
.init()
.unwrap();
if let Err(err) = run_app(opt) {
println!("{}", err.display_chain());
std::process::exit(-1);
}
Ok(())
}

29
core/Cargo.toml 100644
View File

@ -0,0 +1,29 @@
[package]
name = "mailpot"
version = "0.1.0"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2018"
license = "LICENSE"
readme = "README.md"
description = "mailing list manager"
repository = "https://github.com/meli/mailpot"
keywords = ["mail", "mailing-lists"]
categories = ["email"]
[dependencies]
anyhow = "1.0.58"
chrono = { version = "^0.4", features = ["serde", ] }
error-chain = { version = "0.12.4", default-features = false }
log = "0.4"
melib = { version = "*", default-features = false, features = ["smtp", "unicode_algorithms", "maildir_backend"], git = "https://github.com/meli/meli", rev = "2447a2c" }
rusqlite = { version = "^0.28", features = ["bundled", "trace", "hooks"] }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
toml = "^0.5"
xdg = "2.4.1"
[dev-dependencies]
mailin-embedded = { version = "0.7", features = ["rtls"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "blocking"] }
stderrlog = "^0.5"
tempfile = "3.3.0"

View File

@ -17,53 +17,17 @@
* 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";
use std::io::Write;
use std::process::{Command, Stdio};
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")
let 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()))
let mut verify = Command::new("sqlite3")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@ -87,9 +51,4 @@ fn main() {
}
let mut file = std::fs::File::create("./src/schema.sql").unwrap();
file.write_all(&output.stdout).unwrap();
file.write_all(
&format!("\n\n-- Set current schema version.\n\nPRAGMA user_version = {user_version};\n")
.as_bytes(),
)
.unwrap();
}

View File

@ -17,15 +17,11 @@
* 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::*;
use chrono::prelude::*;
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
/// How to send e-mail.
#[derive(Debug, Serialize, Deserialize, Clone)]
@ -33,8 +29,7 @@ use super::errors::*;
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.
/// A plain shell command passed to `sh -c` with the e-mail passed in the stdin.
ShellCommand(String),
}
@ -47,27 +42,21 @@ pub struct Configuration {
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).
/// 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 {
Configuration {
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,
}
}
@ -76,18 +65,12 @@ impl Configuration {
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()
)
})?;
let mut file = std::fs::File::open(path)?;
file.read_to_string(&mut s)?;
let config: Configuration = toml::from_str(&s).context(format!(
"Could not parse configuration file `{}` succesfully: ",
path.display()
))?;
Ok(config)
}
@ -110,20 +93,14 @@ impl Configuration {
}
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 file = std::fs::File::create(&path)?;
let metadata = file.metadata()?;
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()))?;
file.set_permissions(permissions)?;
file.write_all(msg.as_bytes())?;
file.flush()?;
Ok(path)
}
@ -139,29 +116,3 @@ impl Configuration {
.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()
),
);
}
}

659
core/src/db.rs 100644
View File

@ -0,0 +1,659 @@
/*
* 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/>.
*/
//! Mailpot database and methods.
use super::Configuration;
use super::*;
use crate::ErrorKind::*;
use melib::Envelope;
use models::changesets::*;
use rusqlite::Connection as DbConnection;
use rusqlite::OptionalExtension;
use std::convert::TryFrom;
use std::io::Write;
use std::process::{Command, Stdio};
/// A connection to a `mailpot` database.
pub struct Connection {
/// The `rusqlite` connection handle.
pub connection: DbConnection,
conf: Configuration,
}
mod error_queue;
pub use error_queue::*;
mod posts;
pub use posts::*;
mod members;
pub use members::*;
fn log_callback(error_code: std::ffi::c_int, message: &str) {
match error_code {
rusqlite::ffi::SQLITE_NOTICE => log::info!("{}", message),
rusqlite::ffi::SQLITE_WARNING => log::warn!("{}", message),
_ => log::error!("{error_code} {}", message),
}
}
fn user_authorizer_callback(
auth_context: rusqlite::hooks::AuthContext<'_>,
) -> rusqlite::hooks::Authorization {
use rusqlite::hooks::{AuthAction, Authorization};
// [ref:sync_auth_doc] sync with `untrusted()` rustdoc when changing this.
match auth_context.action {
AuthAction::Delete {
table_name: "error_queue" | "queue" | "candidate_membership" | "membership",
}
| AuthAction::Insert {
table_name: "post" | "error_queue" | "queue" | "candidate_membership" | "membership",
}
| AuthAction::Select
| AuthAction::Savepoint { .. }
| AuthAction::Transaction { .. }
| AuthAction::Read { .. }
| AuthAction::Function {
function_name: "strftime",
} => Authorization::Allow,
_ => Authorization::Deny,
}
}
impl Connection {
/// Creates a new database connection.
///
/// `Connection` supports a limited subset of operations by default (see
/// [`Connection::untrusted`]).
/// Use [`Connection::trusted`] to remove these limits.
pub fn open_db(conf: Configuration) -> Result<Self> {
use rusqlite::config::DbConfig;
use std::sync::Once;
static INIT_SQLITE_LOGGING: Once = Once::new();
if !conf.db_path.exists() {
return Err("Database doesn't exist".into());
}
INIT_SQLITE_LOGGING.call_once(|| {
unsafe { rusqlite::trace::config_log(Some(log_callback)).unwrap() };
});
let conn = DbConnection::open(conf.db_path.to_str().unwrap())?;
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_FKEY, true)?;
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)?;
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, false)?;
conn.busy_timeout(core::time::Duration::from_millis(500))?;
conn.busy_handler(Some(|times: i32| -> bool { times < 5 }))?;
conn.authorizer(Some(user_authorizer_callback));
Ok(Connection {
conf,
connection: conn,
})
}
/// Removes operational limits from this connection. (see [`Connection::untrusted`])
#[must_use]
pub fn trusted(self) -> Self {
self.connection
.authorizer::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>(
None,
);
self
}
// [tag:sync_auth_doc]
/// Sets operational limits for this connection.
///
/// - Allow `INSERT`, `DELETE` only for "error_queue", "queue", "candidate_membership", "membership".
/// - Allow `INSERT` only for "post".
/// - Allow read access to all tables.
/// - Allow `SELECT`, `TRANSACTION`, `SAVEPOINT`, and the `strftime` function.
/// - Deny everything else.
pub fn untrusted(self) -> Self {
self.connection.authorizer(Some(user_authorizer_callback));
self
}
/// Create a database if it doesn't exist and then open it.
pub fn open_or_create_db(conf: Configuration) -> Result<Self> {
if !conf.db_path.exists() {
let db_path = &conf.db_path;
use std::os::unix::fs::PermissionsExt;
info!("Creating database in {}", db_path.display());
std::fs::File::create(db_path).context("Could not create db path")?;
let mut child = Command::new("sqlite3")
.arg(db_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().unwrap();
std::thread::spawn(move || {
stdin
.write_all(include_bytes!("./schema.sql"))
.expect("failed to write to stdin");
stdin.flush().expect("could not flush stdin");
});
let output = child.wait_with_output()?;
if !output.status.success() {
return Err(format!("Could not initialize sqlite3 database at {}: sqlite3 returned exit code {} and stderr {} {}", db_path.display(), output.status.code().unwrap_or_default(), String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout)).into());
}
let file = std::fs::File::open(db_path)?;
let metadata = file.metadata()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600); // Read/write for owner only.
file.set_permissions(permissions)?;
}
Self::open_db(conf)
}
/// Returns a connection's configuration.
pub fn conf(&self) -> &Configuration {
&self.conf
}
/// Loads archive databases from [`Configuration::data_path`], if any.
pub fn load_archives(&self) -> Result<()> {
let mut stmt = self.connection.prepare("ATTACH ? AS ?;")?;
for archive in std::fs::read_dir(&self.conf.data_path)? {
let archive = archive?;
let path = archive.path();
let name = path.file_name().unwrap_or_default();
if path == self.conf.db_path {
continue;
}
stmt.execute(rusqlite::params![
path.to_str().unwrap(),
name.to_str().unwrap()
])?;
}
Ok(())
}
/// Returns a vector of existing mailing lists.
pub fn lists(&self) -> Result<Vec<DbVal<MailingList>>> {
let mut stmt = self.connection.prepare("SELECT * FROM mailing_lists;")?;
let list_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")?,
archive_url: row.get("archive_url")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Fetch a mailing list by primary key.
pub fn list(&self, pk: i64) -> Result<DbVal<MailingList>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM mailing_lists WHERE pk = ?;")?;
let ret = stmt
.query_row([&pk], |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")?,
archive_url: row.get("archive_url")?,
},
pk,
))
})
.optional()?;
if let Some(ret) = ret {
Ok(ret)
} else {
Err(Error::from(NotFound("list or list policy not found!")))
}
}
/// Fetch a mailing list by id.
pub fn list_by_id<S: AsRef<str>>(&self, id: S) -> Result<Option<DbVal<MailingList>>> {
let id = id.as_ref();
let mut stmt = self
.connection
.prepare("SELECT * FROM mailing_lists WHERE id = ?;")?;
let ret = stmt
.query_row([&id], |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")?,
archive_url: row.get("archive_url")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Create a new list.
pub fn create_list(&self, new_val: MailingList) -> Result<DbVal<MailingList>> {
let mut stmt = self
.connection
.prepare("INSERT INTO mailing_lists(name, id, address, description, archive_url) VALUES(?, ?, ?, ?, ?) RETURNING *;")?;
let ret = stmt.query_row(
rusqlite::params![
&new_val.name,
&new_val.id,
&new_val.address,
new_val.description.as_ref(),
new_val.archive_url.as_ref(),
],
|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")?,
archive_url: row.get("archive_url")?,
},
pk,
))
},
)?;
trace!("create_list {:?}.", &ret);
Ok(ret)
}
/// Remove an existing list policy.
///
/// ```
/// # 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(),
/// # };
///
/// # fn do_test(config: Configuration) {
/// let db = Connection::open_or_create_db(config).unwrap().trusted();
/// 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,
/// archive_url: None,
/// }).unwrap().pk;
/// db.set_list_policy(
/// PostPolicy {
/// pk: 0,
/// list: list_pk,
/// announce_only: false,
/// subscriber_only: true,
/// approval_needed: false,
/// no_subscriptions: false,
/// custom: false,
/// },
/// ).unwrap();
/// db.remove_list_policy(1, 1).unwrap();
/// # }
/// # do_test(config);
/// ```
pub fn remove_list_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_policy {} {}.", list_pk, policy_pk);
Ok(())
}
/// ```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(),
/// # };
///
/// # fn do_test(config: Configuration) {
/// let db = Connection::open_or_create_db(config).unwrap().trusted();
/// db.remove_list_policy(1, 1).unwrap();
/// # }
/// # do_test(config);
/// ```
#[cfg(doc)]
pub fn remove_list_policy_panic() {}
/// Set the unique post policy for a list.
pub fn set_list_policy(&self, policy: PostPolicy) -> Result<DbVal<PostPolicy>> {
if !(policy.announce_only
|| policy.subscriber_only
|| policy.approval_needed
|| policy.no_subscriptions
|| policy.custom)
{
return Err(
"Cannot add empty policy. Having no policies is probably what you want to do."
.into(),
);
}
let list_pk = policy.list;
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO post_policy(list, announce_only, subscriber_only, approval_needed, no_subscriptions, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
let ret = stmt
.query_row(
rusqlite::params![
&list_pk,
&policy.announce_only,
&policy.subscriber_only,
&policy.approval_needed,
&policy.no_subscriptions,
&policy.custom,
],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
PostPolicy {
pk,
list: row.get("list")?,
announce_only: row.get("announce_only")?,
subscriber_only: row.get("subscriber_only")?,
approval_needed: row.get("approval_needed")?,
no_subscriptions: row.get("no_subscriptions")?,
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_policy {:?}.", &ret);
Ok(ret)
}
/// Fetch all posts of a mailing list.
pub fn list_posts(
&self,
list_pk: i64,
_date_range: Option<(String, String)>,
) -> Result<Vec<DbVal<Post>>> {
let mut stmt = self
.connection
.prepare("SELECT pk, list, address, message_id, message, timestamp, datetime, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') as month_year FROM post WHERE list = ?;")?;
let iter = stmt.query_map(rusqlite::params![&list_pk,], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Post {
pk,
list: row.get("list")?,
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,
))
})?;
let mut ret = vec![];
for post in iter {
let post = post?;
ret.push(post);
}
trace!("list_posts {:?}.", &ret);
Ok(ret)
}
/// Fetch the post policy of a mailing list.
pub fn list_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")?,
subscriber_only: row.get("subscriber_only")?,
approval_needed: row.get("approval_needed")?,
no_subscriptions: row.get("no_subscriptions")?,
custom: row.get("custom")?,
},
pk,
))
})
.optional()?;
Ok(ret)
}
/// Fetch the owners of a mailing list.
pub fn list_owners(&self, pk: i64) -> Result<Vec<DbVal<ListOwner>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM list_owner WHERE list = ?;")?;
let list_iter = stmt.query_map([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListOwner {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Remove an owner of a mailing list.
pub fn remove_list_owner(&self, list_pk: i64, owner_pk: i64) -> Result<()> {
self.connection
.query_row(
"DELETE FROM list_owner WHERE list = ? AND pk = ? RETURNING *;",
rusqlite::params![&list_pk, &owner_pk],
|_| 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(())
}
/// Add an owner of a mailing list.
pub fn add_list_owner(&self, list_owner: ListOwner) -> Result<DbVal<ListOwner>> {
let mut stmt = self.connection.prepare(
"INSERT OR REPLACE INTO list_owner(list, address, name) VALUES (?, ?, ?) RETURNING *;",
)?;
let list_pk = list_owner.list;
let ret = stmt
.query_row(
rusqlite::params![&list_pk, &list_owner.address, &list_owner.name,],
|row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListOwner {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
},
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_list_owner {:?}.", &ret);
Ok(ret)
}
/// Update a mailing list.
pub fn update_list(&mut self, change_set: MailingListChangeset) -> Result<()> {
if matches!(
change_set,
MailingListChangeset {
pk: _,
name: None,
id: None,
address: None,
description: None,
archive_url: None
}
) {
return self.list(change_set.pk).map(|_| ());
}
let MailingListChangeset {
pk,
name,
id,
address,
description,
archive_url,
} = change_set;
let tx = self.connection.transaction()?;
macro_rules! update {
($field:tt) => {{
if let Some($field) = $field {
tx.execute(
concat!(
"UPDATE mailing_lists SET ",
stringify!($field),
" = ? WHERE pk = ?;"
),
rusqlite::params![&$field, &pk],
)?;
}
}};
}
update!(name);
update!(id);
update!(address);
update!(description);
update!(archive_url);
tx.commit()?;
Ok(())
}
/// Return the post filters of a mailing list.
pub fn list_filters(
&self,
_list: &DbVal<MailingList>,
) -> Vec<Box<dyn crate::mail::message_filters::PostFilter>> {
use crate::mail::message_filters::*;
vec![
Box::new(FixCRLF),
Box::new(PostRightsCheck),
Box::new(AddListHeaders),
Box::new(FinalizeRecipients),
]
}
}

View File

@ -0,0 +1,92 @@
/*
* 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 super::*;
use serde_json::{json, Value};
impl Connection {
/// Insert a received email into the error queue.
pub fn insert_to_error_queue(&self, env: &Envelope, raw: &[u8], reason: String) -> Result<i64> {
let mut stmt = self.connection.prepare("INSERT INTO error_queue(error, to_address, from_address, subject, message_id, message, timestamp, datetime) VALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING pk;")?;
let pk = stmt.query_row(
rusqlite::params![
&reason,
&env.field_to_to_string(),
&env.field_from_to_string(),
&env.subject(),
&env.message_id().to_string(),
raw,
&env.timestamp,
&env.date,
],
|row| {
let pk: i64 = row.get("pk")?;
Ok(pk)
},
)?;
Ok(pk)
}
/// Fetch all error queue entries.
pub fn error_queue(&self) -> Result<Vec<DbVal<Value>>> {
let mut stmt = self.connection.prepare("SELECT * FROM error_queue;")?;
let error_iter = stmt.query_map([], |row| {
let pk = row.get::<_, i64>("pk")?;
Ok(DbVal(
json!({
"pk" : pk,
"error": row.get::<_, String>("error")?,
"to_address": row.get::<_, String>("to_address")?,
"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::<_, String>("datetime")?,
}),
pk,
))
})?;
let mut ret = vec![];
for error in error_iter {
let error = error?;
ret.push(error);
}
Ok(ret)
}
/// Delete error queue entries.
pub fn delete_from_error_queue(&mut self, index: Vec<i64>) -> Result<()> {
let tx = self.connection.transaction()?;
if index.is_empty() {
tx.execute("DELETE FROM error_queue;", [])?;
} else {
for i in index {
tx.execute(
"DELETE FROM error_queue WHERE pk = ?;",
rusqlite::params![i],
)?;
}
};
tx.commit()?;
Ok(())
}
}

View File

@ -0,0 +1,293 @@
/*
* 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 super::*;
impl Connection {
/// Fetch all members of a mailing list.
pub fn list_members(&self, pk: i64) -> Result<Vec<DbVal<ListMembership>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM membership WHERE list = ?;")?;
let list_iter = stmt.query_map([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListMembership {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
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")?,
enabled: row.get("enabled")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Fetch mailing list member.
pub fn list_member(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListMembership>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM membership 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(
ListMembership {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
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")?,
enabled: row.get("enabled")?,
},
pk,
))
})?;
Ok(ret)
}
/// Fetch mailing list member by their address.
pub fn list_member_by_address(
&self,
list_pk: i64,
address: &str,
) -> Result<DbVal<ListMembership>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM membership 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(
ListMembership {
pk,
list: row.get("list")?,
address: address_,
name: row.get("name")?,
digest: row.get("digest")?,
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")?,
enabled: row.get("enabled")?,
},
pk,
))
})?;
Ok(ret)
}
/// Add member to mailing list.
pub fn add_member(
&self,
list_pk: i64,
mut new_val: ListMembership,
) -> Result<DbVal<ListMembership>> {
new_val.list = list_pk;
let mut stmt = self
.connection
.prepare("INSERT INTO membership(list, address, name, enabled, digest, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;")?;
let ret = stmt.query_row(
rusqlite::params![
&new_val.list,
&new_val.address,
&new_val.name,
&new_val.enabled,
&new_val.digest,
&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(
ListMembership {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
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")?,
enabled: row.get("enabled")?,
},
pk,
))
},
)?;
trace!("add_member {:?}.", &ret);
Ok(ret)
}
/// Create membership candidate.
pub fn add_candidate_member(&self, list_pk: i64, mut new_val: ListMembership) -> Result<i64> {
new_val.list = list_pk;
let mut stmt = self
.connection
.prepare("INSERT INTO candidate_membership(list, address, name, accepted) VALUES(?, ?, ?, ?) RETURNING pk;")?;
let ret = stmt.query_row(
rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
|row| {
let pk: i64 = row.get("pk")?;
Ok(pk)
},
)?;
trace!("add_candidate_member {:?}.", &ret);
Ok(ret)
}
/// Accept membership candidate.
pub fn accept_candidate_member(&mut self, pk: i64) -> Result<DbVal<ListMembership>> {
let tx = self.connection.transaction()?;
let mut stmt = tx
.prepare("INSERT INTO membership(list, address, name, enabled, digest, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) FROM (SELECT list, address, name FROM candidate_membership WHERE pk = ?) RETURNING *;")?;
let ret = stmt.query_row(rusqlite::params![&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListMembership {
pk,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
digest: row.get("digest")?,
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")?,
enabled: row.get("enabled")?,
},
pk,
))
})?;
drop(stmt);
tx.execute(
"UPDATE candidate_membership SET accepted = ? WHERE pk = ?;",
[&ret.pk, &pk],
)?;
tx.commit()?;
trace!("accept_candidate_member {:?}.", &ret);
Ok(ret)
}
/// Remove a member by their address.
pub fn remove_membership(&self, list_pk: i64, address: &str) -> Result<()> {
self.connection
.query_row(
"DELETE FROM membership WHERE list_pk = ? 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 membership.
pub fn update_member(&mut self, change_set: ListMembershipChangeset) -> Result<()> {
let pk = self
.list_member_by_address(change_set.list, &change_set.address)?
.pk;
if matches!(
change_set,
ListMembershipChangeset {
list: _,
address: _,
name: None,
digest: None,
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
enabled: None,
}
) {
return Ok(());
}
let ListMembershipChangeset {
list,
address: _,
name,
digest,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
enabled,
} = change_set;
let tx = self.connection.transaction()?;
macro_rules! update {
($field:tt) => {{
if let Some($field) = $field {
tx.execute(
concat!(
"UPDATE membership SET ",
stringify!($field),
" = ? WHERE list = ? AND pk = ?;"
),
rusqlite::params![&$field, &list, &pk],
)?;
}
}};
}
update!(name);
update!(digest);
update!(hide_address);
update!(receive_duplicates);
update!(receive_own_posts);
update!(receive_confirmation);
update!(enabled);
tx.commit()?;
Ok(())
}
}

View File

@ -0,0 +1,333 @@
/*
* 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 super::*;
use crate::mail::ListRequest;
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 mut datetime: std::borrow::Cow<'_, str> = env.date.as_str().into();
if env.timestamp != 0 {
datetime = melib::datetime::timestamp_to_string(
env.timestamp,
Some(melib::datetime::RFC3339_FMT_WITH_TIME),
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.
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_error_queue(env, raw, err.to_string()) {
Ok(idx) => Err(Error::from_kind(Information(format!(
"Inserted into error_queue at index {}",
idx
)))
.chain_err(|| err)),
Err(err2) => 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()?;
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
});
}
}
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 Ok(());
}
trace!("Configuration is {:#?}", &self.conf);
use crate::mail::{ListContext, Post, PostAction};
for mut list in lists {
trace!("Examining list {}", list.display_name());
let filters = self.list_filters(&list);
let memberships = self.list_members(list.pk)?;
let owners = self.list_owners(list.pk)?;
trace!("List members {:#?}", &memberships);
let mut list_ctx = ListContext {
policy: self.list_policy(list.pk)?,
list_owners: &owners,
list: &mut list,
memberships: &memberships,
scheduled_jobs: vec![],
};
let mut post = Post {
from: env.from()[0].clone(),
bytes: raw.to_vec(),
to: env.to().to_vec(),
action: PostAction::Hold,
};
let result = filters
.into_iter()
.fold(Ok((&mut post, &mut list_ctx)), |p, f| {
p.and_then(|(p, c)| f.feed(p, c))
});
trace!("result {:#?}", result);
let Post { 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() {
if let crate::config::SendMail::Smtp(ref smtp_conf) =
&self.conf.send_mail
{
let smtp_conf = smtp_conf.clone();
use melib::futures;
use melib::smol;
use melib::smtp::*;
let mut conn = smol::future::block_on(smol::spawn(
SmtpConnection::new_connection(smtp_conf.clone()),
))?;
futures::executor::block_on(conn.mail_transaction(
&String::from_utf8_lossy(&bytes),
Some(recipients),
))?;
}
} else {
trace!("list has no recipients");
}
}
}
/* - FIXME Save digest metadata in database */
}
PostAction::Reject { reason } => {
/* FIXME - Notify submitter */
trace!("PostAction::Reject {{ reason: {} }}", reason);
//futures::executor::block_on(conn.mail_transaction(&post.bytes, b)).unwrap();
return Err(PostRejected(reason).into());
}
PostAction::Defer { reason } => {
trace!("PostAction::Defer {{ reason: {} }}", reason);
/* - FIXME Notify submitter
* - FIXME Save in database */
}
PostAction::Hold => {
trace!("PostAction::Hold");
/* FIXME - Save in database */
}
}
}
Ok(())
}
/// Process a new mailing list request.
pub fn request(
&self,
list: &DbVal<MailingList>,
request: ListRequest,
env: &Envelope,
_raw: &[u8],
) -> Result<()> {
match request {
ListRequest::Subscribe => {
trace!(
"subscribe action for addresses {:?} in list {}",
env.from(),
list
);
let list_policy = self.list_policy(list.pk)?;
let approval_needed = list_policy
.as_ref()
.map(|p| p.approval_needed)
.unwrap_or(false);
for f in env.from() {
let membership = ListMembership {
pk: 0,
list: list.pk,
address: f.get_email(),
name: f.get_display_name(),
digest: false,
hide_address: false,
receive_duplicates: true,
receive_own_posts: false,
receive_confirmation: true,
enabled: !approval_needed,
};
if approval_needed {
match self.add_candidate_member(list.pk, membership) {
Ok(_) => {}
Err(_err) => {}
}
//FIXME: send notification to list-owner
} else if let Err(_err) = self.add_member(list.pk, membership) {
//FIXME: send failure notice to f
} else {
//FIXME: send success notice
}
}
}
ListRequest::Unsubscribe => {
trace!(
"unsubscribe action for addresses {:?} in list {}",
env.from(),
list
);
for f in env.from() {
if let Err(_err) = self.remove_membership(list.pk, &f.get_email()) {
//FIXME: send failure notice to f
} else {
//FIXME: send success notice to f
}
}
}
ListRequest::Other(ref req) if req == "owner" => {
trace!(
"list-owner mail action for addresses {:?} in list {}",
env.from(),
list
);
//FIXME: mail to list-owner
}
ListRequest::RetrieveMessages(ref message_ids) => {
trace!(
"retrieve messages {:?} action for addresses {:?} in list {}",
message_ids,
env.from(),
list
);
//FIXME
}
ListRequest::RetrieveArchive(ref from, ref to) => {
trace!(
"retrieve archie action from {:?} to {:?} for addresses {:?} in list {}",
from,
to,
env.from(),
list
);
//FIXME
}
ListRequest::SetDigest(ref toggle) => {
trace!(
"set digest action with value {} for addresses {:?} in list {}",
toggle,
env.from(),
list
);
}
ListRequest::Other(ref req) => {
trace!(
"unknown request action {} for addresses {:?} in list {}",
req,
env.from(),
list
);
}
}
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)
}
}

60
core/src/errors.rs 100644
View File

@ -0,0 +1,60 @@
/*
* 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.
pub use crate::anyhow::Context;
pub use error_chain::ChainedError;
// Create the Error, ErrorKind, ResultExt, and Result types
error_chain! {
errors {
/// Post rejected.
PostRejected(reason: String) {
description("Post rejected")
display("Your post has been rejected: {}", reason)
}
/// An entry was not found in the database.
NotFound(model: &'static str) {
description("Not found")
display("This {} is not present in the database.", model)
}
/// A request was invalid.
InvalidRequest(reason: String) {
description("List request is invalid")
display("Your list request has been found invalid: {}.", reason)
}
/// An error happened and it was handled internally.
Information(reason: String) {
description("")
display("{}.", reason)
}
}
foreign_links {
Logic(anyhow::Error) #[doc="Error returned from an external user initiated operation such as deserialization or I/O."];
Sql(rusqlite::Error) #[doc="Error returned from sqlite3."];
Io(::std::io::Error) #[doc="Error returned from internal I/O operations."];
Melib(melib::error::Error) #[doc="Error returned from e-mail protocol operations from `melib` crate."];
SerdeJson(serde_json::Error) #[doc="Error from deserializing JSON values."];
}
}

133
core/src/lib.rs 100644
View File

@ -0,0 +1,133 @@
/*
* 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/>.
*/
#![warn(missing_docs)]
//! Mailing list manager library.
//!
//! ```
//! 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(),
//! # };
//! #
//! # 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(),
//! description: None,
//! archive_url: None,
//! })?.pk;
//!
//! db.set_list_policy(
//! PostPolicy {
//! pk: 0,
//! list: list_pk,
//! announce_only: false,
//! subscriber_only: true,
//! approval_needed: false,
//! no_subscriptions: false,
//! custom: false,
//! },
//! )?;
//!
//! // Drop privileges; we can only process new e-mail and modify memberships from now on.
//! let db = db.untrusted();
//!
//! assert_eq!(db.list_members(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_members(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_members(list_pk)?.len(), 1);
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
//! # Ok(())
//! # }
//! # do_test(config);
//! ```
#[macro_use]
extern crate error_chain;
extern crate anyhow;
#[macro_use]
pub extern crate serde;
pub extern crate log;
pub extern crate melib;
pub extern crate serde_json;
use log::{info, trace};
mod config;
pub mod mail;
pub mod models;
use models::*;
mod db;
mod errors;
pub use config::{Configuration, SendMail};
pub use db::*;
pub use errors::*;
/// 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;

View File

@ -17,21 +17,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Types for processing new posts:
//! [`PostFilter`](crate::message_filters::PostFilter), [`ListContext`],
//! Types for processing new posts: [`PostFilter`](message_filters::PostFilter), [`ListContext`],
//! [`MailJob`] and [`PostAction`].
use std::collections::HashMap;
use super::*;
use melib::Address;
pub mod message_filters;
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.
/// Post action returned from a list's [`PostFilter`](message_filters::PostFilter) stack.
#[derive(Debug)]
pub enum PostAction {
/// Add to `hold` queue.
@ -50,49 +43,37 @@ pub enum PostAction {
},
}
/// List context passed to a list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
/// List context passed to a list's [`PostFilter`](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 memberships.
pub memberships: &'list [DbVal<ListMembership>],
/// 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 policy: Option<DbVal<PostPolicy>>,
/// The scheduled jobs added by each filter in a list's [`PostFilter`](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 {
/// Post to be considered by the list's [`PostFilter`](message_filters::PostFilter) stack.
pub struct Post {
/// `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.
/// Final action set by each filter in a list's [`PostFilter`](message_filters::PostFilter) stack.
pub action: PostAction,
/// Post's Message-ID
pub message_id: MessageID,
}
impl core::fmt::Debug for PostEntry {
impl core::fmt::Debug for Post {
fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::fmt::Result {
fmt.debug_struct(stringify!(PostEntry))
fmt.debug_struct("Post")
.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)
@ -100,8 +81,7 @@ impl core::fmt::Debug for PostEntry {
}
}
/// Scheduled jobs added to a [`ListContext`] by a list's
/// [`PostFilter`](crate::message_filters::PostFilter) stack.
/// Scheduled jobs added to a [`ListContext`] by a list's [`PostFilter`](message_filters::PostFilter) stack.
#[derive(Debug)]
pub enum MailJob {
/// Send post to recipients.
@ -134,20 +114,16 @@ pub enum MailJob {
/// 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.
/// Request reception of specific mailing list posts from `Message-ID` values.
RetrieveMessages(Vec<String>),
/// Request change in subscription settings.
/// See [`ListSubscription`].
ChangeSetting(String, bool),
/// Request change in digest preferences. (See [`ListMembership`])
SetDigest(bool),
/// Other type of request.
Other(String),
}
@ -158,23 +134,22 @@ impl std::fmt::Display for ListRequest {
}
}
impl<S: AsRef<str>> TryFrom<(S, &melib::Envelope)> for ListRequest {
impl<S: AsRef<str>> std::convert::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()),
"subscribe" | "request" if env.subject().trim() == "subscribe" => {
ListRequest::Subscribe
}
"unsubscribe" | "request" if env.subject().trim() == "unsubscribe" => {
ListRequest::Unsubscribe
}
"request" => ListRequest::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())
ListRequest::Other(val.trim().to_string())
}
})
}

View File

@ -0,0 +1,227 @@
/*
* 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.
use super::*;
/// 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 Post,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut Post, &'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 Post,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
trace!("Running PostRightsCheck filter");
if let Some(ref policy) = ctx.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.subscriber_only {
trace!("post policy is subscriber_only");
let email_from = post.from.get_email();
trace!("post from is {:?}", &email_from);
trace!("post memberships are {:#?}", &ctx.memberships);
if !ctx.memberships.iter().any(|lm| lm.address == email_from) {
trace!("Envelope from is not subscribed to this list");
post.action = PostAction::Reject {
reason: "Only subscribers can post to this list.".to_string(),
};
return Err(());
}
} else if policy.approval_needed {
trace!("post policy says approval_needed");
post.action = PostAction::Defer {
reason: "Your posting has been deferred. Approval from the list's moderators is required before it is submitted.".to_string(),
};
}
}
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 Post,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut Post, &'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 Post,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
trace!("Running AddListHeaders filter");
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
let list_id = ctx.list.display_name();
let sender = format!("<{}>", ctx.list.address);
headers.push((&b"List-ID"[..], list_id.as_bytes()));
headers.push((&b"Sender"[..], sender.as_bytes()));
let list_post = ctx.list.post_header();
let list_unsubscribe = ctx.list.unsubscribe_header();
let list_archive = ctx.list.archive_header();
if let Some(post) = list_post.as_ref() {
headers.push((&b"List-Post"[..], post.as_bytes()));
}
if let Some(unsubscribe) = list_unsubscribe.as_ref() {
headers.push((&b"List-Unsubscribe"[..], unsubscribe.as_bytes()));
}
if let Some(archive) = list_archive.as_ref() {
headers.push((&b"List-Archive"[..], archive.as_bytes()));
}
let mut new_vec = Vec::with_capacity(
headers
.iter()
.map(|(h, v)| h.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);
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 Post,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
trace!("Running ArchivedAtLink filter");
Ok((post, ctx))
}
}
/// Assuming there are no more changes to be done on the post, it finalizes which list members
/// 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 Post,
ctx: &'p mut ListContext<'list>,
) -> std::result::Result<(&'p mut Post, &'p mut ListContext<'list>), ()> {
trace!("Running FinalizeRecipients filter");
let mut recipients = vec![];
let mut digests = vec![];
let email_from = post.from.get_email();
for member in ctx.memberships {
trace!("examining member {:?}", &member);
if member.address == email_from {
trace!("member is submitter");
}
if member.digest {
if member.address != email_from || member.receive_own_posts {
trace!("Member gets digest");
digests.push(member.address());
}
continue;
}
if member.address != email_from || member.receive_own_posts {
trace!("Member gets copy");
recipients.push(member.address());
}
// TODO:
// - check for duplicates (To,Cc,Bcc)
// - send confirmation to submitter
}
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))
}
}

301
core/src/models.rs 100644
View File

@ -0,0 +1,301 @@
/*
* 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`], [`ListMembership`], [`PostPolicy`] and
//! [`Post`].
use super::*;
pub mod changesets;
use melib::email::Address;
/// A database entry and its primary key. Derefs to its inner type.
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(transparent)]
pub struct DbVal<T>(pub T, #[serde(skip)] pub i64);
impl<T> DbVal<T> {
/// Primary key.
#[inline(always)]
pub fn pk(&self) -> i64 {
self.1
}
}
impl<T> std::ops::Deref for DbVal<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T> std::fmt::Display for DbVal<T>
where
T: std::fmt::Display,
{
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,
/// 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 (e.g. `list name <list_address@example.com>`).
pub fn display_name(&self) -> String {
format!("\"{}\" <{}>", self.name, self.address)
}
/// Value of `List-Post` header.
///
/// See RFC2369 Section 3.4: <https://www.rfc-editor.org/rfc/rfc2369#section-3.4>
pub fn post_header(&self) -> Option<String> {
Some(format!("<mailto:{}>", self.address))
}
/// Value of `List-Unsubscribe` header.
///
/// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
pub fn unsubscribe_header(&self) -> Option<String> {
let p = self.address.split('@').collect::<Vec<&str>>();
Some(format!(
"<mailto:{}-request@{}?subject=subscribe>",
p[0], p[1]
))
}
/// Value of `List-Archive` header.
///
/// See RFC2369 Section 3.6: <https://www.rfc-editor.org/rfc/rfc2369#section-3.6>
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`](super::MailtoAddress).
pub fn unsubscribe_mailto(&self) -> Option<MailtoAddress> {
let p = self.address.split('@').collect::<Vec<&str>>();
Some(MailtoAddress {
address: format!("{}-request@{}", p[0], p[1]),
subject: Some("unsubscribe".to_string()),
})
}
/// List subscribe action as a [`MailtoAddress`](super::MailtoAddress).
pub fn subscribe_mailto(&self) -> Option<MailtoAddress> {
let p = self.address.split('@').collect::<Vec<&str>>();
Some(MailtoAddress {
address: format!("{}-request@{}", p[0], p[1]),
subject: Some("subscribe".to_string()),
})
}
/// List archive url value.
pub fn archive_url(&self) -> Option<&str> {
self.archive_url.as_deref()
}
}
/// A mailing list membership entry.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListMembership {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// Member's e-mail address.
pub address: String,
/// Member's name, optional.
pub name: Option<String>,
/// Whether member wishes to receive list posts as a periodical digest e-mail.
pub digest: bool,
/// Whether member wishes their e-mail address hidden from public view.
pub hide_address: bool,
/// Whether member 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 member wishes to receive their own mailing list posts from the mailing list, as a
/// confirmation.
pub receive_own_posts: bool,
/// Whether member wishes to receive a plain confirmation for their own mailing list posts.
pub receive_confirmation: bool,
/// Whether this membership is enabled.
pub enabled: bool,
}
impl std::fmt::Display for ListMembership {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
fmt,
"{} [digest: {}, hide_address: {} {}]",
self.address(),
self.digest,
self.hide_address,
if self.enabled {
"enabled"
} else {
"not enabled"
},
)
}
}
impl ListMembership {
/// Member 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)]
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 "subscriber only" (Only list subscribers can post).
pub subscriber_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 "no subscriptions" (Anyone can post, but approval from list owners is
/// required. Subscriptions are not enabled).
pub no_subscriptions: 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)]
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 ListMembership {
fn from(val: ListOwner) -> ListMembership {
ListMembership {
pk: 0,
list: val.list,
address: val.address,
name: val.name,
digest: false,
hide_address: false,
receive_duplicates: true,
receive_own_posts: false,
receive_confirmation: true,
enabled: 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, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Post {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`]).
pub list: i64,
/// `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,
/// Datetime as string.
pub datetime: String,
/// Month-year as a `YYYY-mm` formatted string, for use in archives.
pub month_year: String,
}
impl std::fmt::Display for Post {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}

View File

@ -19,18 +19,8 @@
//! 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)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MailingListChangeset {
/// Database primary key.
pub pk: i64,
@ -44,38 +34,20 @@ pub struct MailingListChangeset {
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 {
/// Changeset struct for [`ListMembership`](super::ListMembership).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListMembershipChangeset {
/// Mailing list foreign key (See [`MailingList`](super::MailingList)).
pub list: i64,
/// Subscription e-mail address.
/// Membership 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>,
@ -83,12 +55,27 @@ pub struct ListSubscriptionChangeset {
pub receive_own_posts: Option<bool>,
/// Optional new value.
pub receive_confirmation: Option<bool>,
/// Optional new value.
pub enabled: Option<bool>,
}
impl_display!(ListSubscriptionChangeset);
/// Changeset struct for [`PostPolicy`](super::PostPolicy).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PostPolicyChangeset {
/// Database primary key.
pub pk: i64,
/// Mailing list foreign key (See [`MailingList`](super::MailingList)).
pub list: i64,
/// Optional new value.
pub announce_only: Option<bool>,
/// Optional new value.
pub subscriber_only: Option<bool>,
/// Optional new value.
pub approval_needed: Option<bool>,
}
/// Changeset struct for [`ListOwner`](super::ListOwner).
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListOwnerChangeset {
/// Database primary key.
pub pk: i64,
@ -100,21 +87,24 @@ pub struct ListOwnerChangeset {
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 std::fmt::Display for MailingListChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl_display!(AccountChangeset);
impl std::fmt::Display for ListMembershipChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl std::fmt::Display for PostPolicyChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl std::fmt::Display for ListOwnerChangeset {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}

136
core/src/schema.sql 100644
View File

@ -0,0 +1,136 @@
PRAGMA foreign_keys = true;
PRAGMA encoding = 'UTF-8';
CREATE TABLE IF NOT EXISTS mailing_lists (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
id TEXT NOT NULL,
address TEXT NOT NULL,
archive_url TEXT,
description TEXT
);
CREATE TABLE IF NOT EXISTS list_owner (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
FOREIGN KEY (list) REFERENCES mailing_lists(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,
subscriber_only BOOLEAN CHECK (subscriber_only in (0, 1)) NOT NULL DEFAULT 0,
approval_needed BOOLEAN CHECK (approval_needed in (0, 1)) NOT NULL DEFAULT 0,
no_subscriptions BOOLEAN CHECK (no_subscriptions in (0, 1)) NOT NULL DEFAULT 0,
custom BOOLEAN CHECK (custom in (0, 1)) NOT NULL DEFAULT 0,
CHECK(((custom) OR (((no_subscriptions) OR (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))) AND NOT ((no_subscriptions) AND (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))))) AND NOT ((custom) AND (((no_subscriptions) OR (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))) AND NOT ((no_subscriptions) AND (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only))))))))),
FOREIGN KEY (list) REFERENCES mailing_lists(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS membership (
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,
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,
FOREIGN KEY (list) REFERENCES mailing_lists(pk) ON DELETE CASCADE,
FOREIGN KEY (account) REFERENCES account(pk) ON DELETE CASCADE,
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
);
CREATE TABLE IF NOT EXISTS candidate_membership (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
accepted INTEGER,
FOREIGN KEY (list) REFERENCES mailing_lists(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES membership(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS post (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
message_id TEXT NOT NULL,
message BLOB NOT NULL,
timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
datetime TEXT NOT NULL DEFAULT (datetime())
);
CREATE TABLE IF NOT EXISTS post_event (
pk INTEGER PRIMARY KEY NOT NULL,
post INTEGER NOT NULL,
date INTEGER NOT NULL,
kind CHAR(1) CHECK (kind IN ('R', 'S', 'D', 'B', 'O')) NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (post) REFERENCES post(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS error_queue (
pk INTEGER PRIMARY KEY NOT NULL,
error TEXT NOT NULL,
to_address 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())
);
-- # 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.
CREATE TABLE IF NOT EXISTS queue (
pk INTEGER PRIMARY KEY NOT NULL,
kind TEXT CHECK (kind IN ('maildrop', 'hold', 'deferred', 'corrupt')) NOT NULL,
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())
);
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 mailing_lists_idx ON mailing_lists(id);
CREATE INDEX IF NOT EXISTS membership_idx ON membership(address);

View File

@ -0,0 +1,140 @@
define(xor, `(($1) OR ($2)) AND NOT (($1) AND ($2))')dnl
define(BOOLEAN_TYPE, `$1 BOOLEAN CHECK ($1 in (0, 1)) NOT NULL')dnl
define(BOOLEAN_FALSE, `0')dnl
define(BOOLEAN_TRUE, `1')dnl
PRAGMA foreign_keys = true;
PRAGMA encoding = 'UTF-8';
CREATE TABLE IF NOT EXISTS mailing_lists (
pk INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
id TEXT NOT NULL,
address TEXT NOT NULL,
archive_url TEXT,
description TEXT
);
CREATE TABLE IF NOT EXISTS list_owner (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
FOREIGN KEY (list) REFERENCES mailing_lists(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS post_policy (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL UNIQUE,
BOOLEAN_TYPE(announce_only) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(subscriber_only) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(approval_needed) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(no_subscriptions) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
CHECK(xor(custom, xor(no_subscriptions, xor(approval_needed, xor(announce_only, subscriber_only))))),
FOREIGN KEY (list) REFERENCES mailing_lists(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS membership (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
account INTEGER,
BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE(),
BOOLEAN_TYPE(digest) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(hide_address) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(receive_duplicates) DEFAULT BOOLEAN_TRUE(),
BOOLEAN_TYPE(receive_own_posts) DEFAULT BOOLEAN_FALSE(),
BOOLEAN_TYPE(receive_confirmation) DEFAULT BOOLEAN_TRUE(),
FOREIGN KEY (list) REFERENCES mailing_lists(pk) ON DELETE CASCADE,
FOREIGN KEY (account) REFERENCES account(pk) ON DELETE CASCADE,
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,
BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE()
);
CREATE TABLE IF NOT EXISTS candidate_membership (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
name TEXT,
accepted INTEGER,
FOREIGN KEY (list) REFERENCES mailing_lists(pk) ON DELETE CASCADE,
FOREIGN KEY (accepted) REFERENCES membership(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS post (
pk INTEGER PRIMARY KEY NOT NULL,
list INTEGER NOT NULL,
address TEXT NOT NULL,
message_id TEXT NOT NULL,
message BLOB NOT NULL,
timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
datetime TEXT NOT NULL DEFAULT (datetime())
);
CREATE TABLE IF NOT EXISTS post_event (
pk INTEGER PRIMARY KEY NOT NULL,
post INTEGER NOT NULL,
date INTEGER NOT NULL,
kind CHAR(1) CHECK (kind IN ('R', 'S', 'D', 'B', 'O')) NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (post) REFERENCES post(pk) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS error_queue (
pk INTEGER PRIMARY KEY NOT NULL,
error TEXT NOT NULL,
to_address 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())
);
-- # 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.
CREATE TABLE IF NOT EXISTS queue (
pk INTEGER PRIMARY KEY NOT NULL,
kind TEXT CHECK (kind IN ('maildrop', 'hold', 'deferred', 'corrupt')) NOT NULL,
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())
);
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 mailing_lists_idx ON mailing_lists(id);
CREATE INDEX IF NOT EXISTS membership_idx ON membership(address);

View File

@ -17,21 +17,22 @@
* 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;
mod utils;
use mailpot::{models::*, Configuration, Connection, SendMail};
use std::error::Error;
use tempfile::TempDir;
#[test]
fn test_authorizer() {
init_stderr_logging();
utils::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,
db_path: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap();
@ -44,33 +45,31 @@ fn test_authorizer() {
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 {
db.remove_list_policy(1, 1).unwrap_err(),
db.set_list_policy(PostPolicy {
pk: 0,
list: 1,
announce_only: false,
subscription_only: true,
subscriber_only: true,
approval_needed: false,
open: false,
no_subscriptions: 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()
err.source()
.unwrap()
.downcast_ref::<rusqlite::ffi::Error>()
.unwrap(),
&rusqlite::ffi::Error {
code: rusqlite::ErrorCode::AuthorizationForStatementDenied,
extended_code: 23
},
);
}
assert!(db.lists().unwrap().is_empty());
@ -84,7 +83,6 @@ fn test_authorizer() {
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.map(|_| ()),
@ -95,17 +93,17 @@ fn test_authorizer() {
name: None,
})
.map(|_| ()),
db.set_list_post_policy(PostPolicy {
db.set_list_policy(PostPolicy {
pk: 0,
list: 1,
announce_only: false,
subscription_only: true,
subscriber_only: true,
approval_needed: false,
open: false,
no_subscriptions: false,
custom: false,
})
.map(|_| ()),
db.remove_list_post_policy(1, 1).map(|_| ()),
db.remove_list_policy(1, 1).map(|_| ()),
db.remove_list_owner(1, 1).map(|_| ()),
] {
ok.unwrap();

View File

@ -17,21 +17,21 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
mod utils;
use mailpot::{models::*, Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
#[test]
fn test_init_empty() {
init_stderr_logging();
utils::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,
db_path: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap();
@ -41,15 +41,14 @@ fn test_init_empty() {
#[test]
fn test_list_creation() {
init_stderr_logging();
utils::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,
db_path: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
@ -61,7 +60,6 @@ fn test_list_creation() {
id: "foo-chat".into(),
address: "foo-chat@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();

View File

@ -17,8 +17,9 @@
* 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;
mod utils;
use mailpot::{melib, models::*, Configuration, Connection, SendMail};
use tempfile::TempDir;
fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
@ -35,15 +36,14 @@ fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
#[test]
fn test_error_queue() {
init_stderr_logging();
utils::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,
db_path: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_or_create_db(config).unwrap().trusted();
@ -55,42 +55,38 @@ fn test_error_queue() {
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 {
.set_list_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscription_only: true,
subscriber_only: true,
approval_needed: false,
open: false,
no_subscriptions: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
assert_eq!(db.error_queue().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)
);
match db
.post(&envelope, input_bytes, /* dry_run */ false)
.unwrap_err()
.kind()
{
mailpot::ErrorKind::PostRejected(_reason) => {}
other => panic!("Got unexpected error: {}", other),
}
assert_eq!(db.error_queue().unwrap().len(), 1);
}

410
core/tests/smtp.rs 100644
View File

@ -0,0 +1,410 @@
/*
* 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/>.
*/
mod utils;
use log::{trace, warn};
use mailin_embedded::{Handler, Response, Server, SslConfig};
use mailpot::{melib, models::*, Configuration, Connection, SendMail};
use std::net::IpAddr; //, Ipv4Addr, Ipv6Addr};
use std::sync::{Arc, Mutex};
use std::thread;
use tempfile::TempDir;
const ADDRESS: &str = "127.0.0.1:8825";
#[derive(Debug, Clone)]
enum Message {
Helo,
Mail {
from: String,
},
Rcpt {
from: String,
to: Vec<String>,
},
DataStart {
from: String,
to: Vec<String>,
},
Data {
#[allow(dead_code)]
from: String,
to: Vec<String>,
buf: Vec<u8>,
},
}
#[derive(Debug, Clone)]
struct MyHandler {
mails: Arc<Mutex<Vec<((IpAddr, String), Message)>>>,
stored: Arc<Mutex<Vec<(String, melib::Envelope)>>>,
}
use mailin_embedded::response::{INTERNAL_ERROR, OK};
impl Handler for MyHandler {
fn helo(&mut self, ip: IpAddr, domain: &str) -> Response {
// eprintln!("helo ip {:?} domain {:?}", ip, domain);
self.mails
.lock()
.unwrap()
.push(((ip, domain.to_string()), Message::Helo));
OK
}
fn mail(&mut self, ip: IpAddr, domain: &str, from: &str) -> Response {
// eprintln!("mail() ip {:?} domain {:?} from {:?}", ip, domain, from);
if let Some((_, message)) = self
.mails
.lock()
.unwrap()
.iter_mut()
.find(|((i, d), _)| (i, d.as_str()) == (&ip, domain))
{
if let Message::Helo = message {
*message = Message::Mail {
from: from.to_string(),
};
return OK;
}
}
INTERNAL_ERROR
}
fn rcpt(&mut self, _to: &str) -> Response {
// eprintln!("rcpt() to {:?}", _to);
if let Some((_, message)) = self.mails.lock().unwrap().last_mut() {
if let Message::Mail { from } = message {
*message = Message::Rcpt {
from: from.clone(),
to: vec![_to.to_string()],
};
return OK;
} else if let Message::Rcpt { to, .. } = message {
to.push(_to.to_string());
return OK;
}
}
INTERNAL_ERROR
}
fn data_start(
&mut self,
_domain: &str,
_from: &str,
_is8bit: bool,
_to: &[String],
) -> Response {
// eprintln!( "data_start() domain {:?} from {:?} is8bit {:?} to {:?}", _domain, _from, _is8bit, _to);
if let Some(((_, d), ref mut message)) = self.mails.lock().unwrap().last_mut() {
if d != _domain {
return INTERNAL_ERROR;
}
if let Message::Rcpt { from, to } = message {
*message = Message::DataStart {
from: from.to_string(),
to: to.to_vec(),
};
return OK;
}
}
INTERNAL_ERROR
}
fn data(&mut self, _buf: &[u8]) -> Result<(), std::io::Error> {
if let Some(((_, _), ref mut message)) = self.mails.lock().unwrap().last_mut() {
if let Message::DataStart { from, to } = message {
*message = Message::Data {
from: from.to_string(),
to: to.clone(),
buf: _buf.to_vec(),
};
return Ok(());
} else if let Message::Data { buf, .. } = message {
buf.extend(_buf.into_iter().copied());
return Ok(());
}
}
Ok(())
}
fn data_end(&mut self) -> Response {
//eprintln!("data_end()");
if let Some(((_, _), message)) = self.mails.lock().unwrap().pop() {
if let Message::Data { from: _, to, buf } = message {
for to in to {
match melib::Envelope::from_bytes(&buf, None) {
Ok(env) => {
self.stored.lock().unwrap().push((to.clone(), env));
}
Err(err) => {
eprintln!("envelope parse error {}", err);
}
}
}
return OK;
}
}
INTERNAL_ERROR
}
}
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_smtp() {
utils::init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();
let handler = MyHandler {
mails: Arc::new(Mutex::new(vec![])),
stored: Arc::new(Mutex::new(vec![])),
};
let handler2 = handler.clone();
let _smtp_handle = thread::spawn(move || {
let mut server = Server::new(handler2);
server
.with_name("example.com")
.with_ssl(SslConfig::None)
.unwrap()
.with_addr(ADDRESS)
.unwrap();
eprintln!("Running smtp server at {}", ADDRESS);
server.serve().expect("Could not run server");
});
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::Smtp(get_smtp_conf()),
db_path: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
};
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,
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let post_policy = db
.set_list_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscriber_only: true,
approval_needed: false,
no_subscriptions: 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);
match db
.post(&envelope, input_bytes, /* dry_run */ false)
.unwrap_err()
.kind()
{
mailpot::ErrorKind::PostRejected(reason) => {
trace!("Non-member post succesfully rejected: '{reason}'");
}
other => panic!("Got unexpected error: {}", other),
}
db.add_member(
foo_chat.pk(),
ListMembership {
pk: 0,
list: foo_chat.pk(),
address: "japoeunp@hotmail.com".into(),
name: Some("Jamaica Poe".into()),
digest: false,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
)
.unwrap();
db.add_member(
foo_chat.pk(),
ListMembership {
pk: 0,
list: foo_chat.pk(),
address: "manos@example.com".into(),
name: Some("Manos Hands".into()),
digest: false,
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);
}
}
assert_eq!(handler.stored.lock().unwrap().len(), 2);
}
#[test]
fn test_smtp_mailcrab() {
use std::env;
utils::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: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
};
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,
archive_url: None,
})
.unwrap();
assert_eq!(foo_chat.pk(), 1);
let post_policy = db
.set_list_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscriber_only: true,
approval_needed: false,
no_subscriptions: 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-member post succesfully rejected: '{reason}'");
}
other => panic!("Got unexpected error: {}", other),
}
db.add_member(
foo_chat.pk(),
ListMembership {
pk: 0,
list: foo_chat.pk(),
address: "japoeunp@hotmail.com".into(),
name: Some("Jamaica Poe".into()),
digest: false,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
)
.unwrap();
db.add_member(
foo_chat.pk(),
ListMembership {
pk: 0,
list: foo_chat.pk(),
address: "manos@example.com".into(),
name: Some("Manos Hands".into()),
digest: false,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: true,
enabled: true,
},
)
.unwrap();
db.post(&envelope, input_bytes, /* dry_run */ false)
.unwrap();
}
Err(err) => {
panic!("Could not parse message: {}", err);
}
}
let mails: String = reqwest::blocking::get(&api_uri).unwrap().text().unwrap();
trace!("mails: {}", mails);
}

View File

@ -0,0 +1,125 @@
/*
* 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/>.
*/
mod utils;
use mailpot::{models::*, Configuration, Connection, SendMail};
use tempfile::TempDir;
#[test]
fn test_list_subscription() {
utils::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: db_path.clone(),
data_path: tmp_dir.path().to_path_buf(),
};
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,
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_policy(PostPolicy {
pk: 0,
list: foo_chat.pk(),
announce_only: false,
subscriber_only: true,
approval_needed: false,
no_subscriptions: false,
custom: false,
})
.unwrap();
assert_eq!(post_policy.pk(), 1);
assert_eq!(db.error_queue().unwrap().len(), 0);
assert_eq!(db.list_members(foo_chat.pk()).unwrap().len(), 0);
let db = db.untrusted();
let input_bytes_1 = 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:
<PS1PR0601MB36750BD00EA89E1482FA98A2D5140@PS1PR0601MB3675.apcprd06.prod.outlook.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(input_bytes_1, None).expect("Could not parse message");
match db
.post(&envelope, input_bytes_1, /* dry_run */ false)
.unwrap_err()
.kind()
{
mailpot::ErrorKind::PostRejected(_reason) => {}
other => panic!("Got unexpected error: {}", other),
}
assert_eq!(db.error_queue().unwrap().len(), 1);
let input_bytes_2 = 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:
<PS1PR0601MB36750BD00EA89E1482FA98A2D5140_2@PS1PR0601MB3675.apcprd06.prod.outlook.com>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
";
let envelope =
melib::Envelope::from_bytes(input_bytes_2, None).expect("Could not parse message");
db.post(&envelope, input_bytes_2, /* dry_run */ false)
.unwrap();
assert_eq!(db.list_members(foo_chat.pk()).unwrap().len(), 1);
assert_eq!(db.error_queue().unwrap().len(), 1);
let envelope =
melib::Envelope::from_bytes(input_bytes_1, None).expect("Could not parse message");
db.post(&envelope, input_bytes_1, /* dry_run */ false)
.unwrap();
assert_eq!(db.error_queue().unwrap().len(), 1);
assert_eq!(db.list_posts(foo_chat.pk(), None).unwrap().len(), 1);
}

View File

@ -0,0 +1,65 @@
Return-Path: <japoeunp@hotmail.com>
Delivered-To: jonnny@miami-dice.co.uk
Received: from violet.xenserver.co.uk
by violet.xenserver.co.uk with LMTP
id qBHcI7LKml9FxzIAYrQLqw
(envelope-from <japoeunp@hotmail.com>)
for <jonnny@miami-dice.co.uk>; Thu, 29 Oct 2020 13:59:14 +0000
Return-path: <japoeunp@hotmail.com>
Envelope-to: jonnny@miami-dice.co.uk
Delivery-date: Thu, 29 Oct 2020 13:59:14 +0000
Received: from mail-oln040092254105.outbound.protection.outlook.com ([40.92.254.105]:29481 helo=APC01-PU1-obe.outbound.protection.outlook.com)
by violet.xenserver.co.uk with esmtps (TLS1.2) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
(Exim 4.93)
(envelope-from <japoeunp@hotmail.com>)
id 1kY8SJ-00DxYw-WD
for jonnny@miami-dice.co.uk; Thu, 29 Oct 2020 13:59:14 +0000
ARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none;
b=KKU/kthPXLl8CnAmBXXsD1QQWr4evL4ymaLwgHgRi5eSnOe2d2sQxrhcZ1VvLSvW2DQEQoNAm6NUtTC5uRUnBDS0n+g1E5/t1z8oFbzdioCIT6rL77ta3MVcaQ/o+gRa6dIwiNfu8z5GxAujOOu57gCfnCw3/gLeOHH01KtP4ezEB/DvAU9bC8eyso1T7nv+HT0riTjZOywGwDHnVb1aIPPIUiOQrrEi+cfLQRiCer01d94U8Wp+FUECrVYbr4uZGl8mbTwU4oZL1rJ25ubYG54e1ktaPJRa2YEitgJEF5sS8Z503c3RjzzBvvHkc/Kl6ypXcovP9xxeoSrS7YIPKA==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com;
s=arcselector9901;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;
bh=NUSuxgSF4fNUuTts93/OAIsK9q9w8XhbybHWH/oRmXo=;
b=VU2clBW8reAfnfCef0DeEDlBzcCU2u288YCjTvB0ekvBkJGSdI657WyS8KR7JSy0KcPWRfGbN9GJaETaasoa7bLdfuB6K9foup+vSqlA1witS5JQXQM/vJCKx67DbT8/8emLrKi7yDD2qjtRsb6HfvbwAGGvmPyUeyfTvRv6js+4YUbe5eN6CCdJEploBXDrWjFXHpSCwVCL1oF6rgrJf0+Td+ufX0QEHbOz2uJWj4yz0A8hK2yV+2JDVW7GiBwZMrO4yLNXYck/0HQRyYFe8I86xUBJWp/0IITCTe96x5L/H3lqmGkh4uRt8IsXT/2jBEm5CmXLxJZAMR8RONG9BQ==
ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none;
dkim=none; arc=none
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=hotmail.com;
s=selector1;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;
bh=NUSuxgSF4fNUuTts93/OAIsK9q9w8XhbybHWH/oRmXo=;
b=JRkih9HxwazdzH6MSzSetJMcRwvDr+e97VnoDCQYJf9qQqgtQvzMZR0Z+d2Gu74Ip3ebcvx5oYlOpV15yVZAqUmUeirpF2rdkmMWQiaDQMq9SLiF09eMDkDfEdGLD4V+C36QIISRamgyagIsC72/UB6OyxpXoAjP0SFxbyItvWVgB9EVVsSJLOKXWgRWiYSZxMLye3OQUqdWoiQ9Tw/o8uywLTvcojOizZaS2SrYWajYScBmMiCh58dUarKzrfXmR/WisfBepCf1ia7BKttjalhuJBcMyKfM923X5IbZ+Yw+gVpLtzwGUyPt2cobOAxKna11whmpWdtoBeXRR/hKOg==
Received: from PU1APC01FT013.eop-APC01.prod.protection.outlook.com
(2a01:111:e400:7ebe::45) by
PU1APC01HT068.eop-APC01.prod.protection.outlook.com (2a01:111:e400:7ebe::323)
with Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.3520.15; Thu, 29 Oct
2020 13:58:16 +0000
Received: from PS1PR0601MB3675.apcprd06.prod.outlook.com
(2a01:111:e400:7ebe::44) by PU1APC01FT013.mail.protection.outlook.com
(2a01:111:e400:7ebe::78) with Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.3520.15 via Frontend
Transport; Thu, 29 Oct 2020 13:58:16 +0000
Received: from PS1PR0601MB3675.apcprd06.prod.outlook.com
([fe80::65ed:e320:1c31:1695]) by PS1PR0601MB3675.apcprd06.prod.outlook.com
([fe80::65ed:e320:1c31:1695%7]) with mapi id 15.20.3499.027; Thu, 29 Oct 2020
13:58:16 +0000
From: Jamaica Poe <japoeunp@hotmail.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
Thread-Topic: thankful that I had the chance to written report, that I could
learn and let alone the chance $4454.32
Thread-Index: AQHWrfuHFQ6EC5DxDEG0hktDfP8BQg==
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID:
<PS1PR0601MB36750BD00EA89E1482FA98A2D5140@PS1PR0601MB3675.apcprd06.prod.outlook.com>
Accept-Language: en-US
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
eT48L2h0bWw+

View File

@ -17,13 +17,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
extern crate base64;
extern crate ureq;
pub use std::path::PathBuf;
use std::sync::Once;
mod args;
pub mod commands;
pub mod import;
pub mod lints;
pub use args::*;
pub use clap::{Args, CommandFactory, Parser, Subcommand};
static INIT_STDERR_LOGGING: Once = Once::new();
pub fn init_stderr_logging() {
INIT_STDERR_LOGGING.call_once(|| {
stderrlog::new()
.quiet(false)
.verbosity(15)
.show_module_names(true)
.timestamp(stderrlog::Timestamp::Millisecond)
.init()
.unwrap();
});
}

53
docs/command.mdoc 100644
View File

@ -0,0 +1,53 @@
.Bl -tag -width Ds -compact -offset indent
.It Ic dump-database
Dumps database data to STDOUT.
.It Ic list-lists
Lists all registered mailing lists.
.It Ic list
Mailing list management.
.It Ic create-list
.Fl -name Ar name
List name.
.Fl -id Ar id
List ID.
.Fl -address Ar address
List e-mail address.
.Fl -description Ar description
List description.
.Fl -archive-url Ar archive-url
List archive URL.
Create new list.
.It Ic post
.Fl -dry-run
.
Post message from STDIN to list.
.It Ic error-queue
Mail that has not been handled properly end up in the error queue.
.It Ic import-maildir
.Fl -maildir-path Ar maildir-path
.
Import a maildir folder into an existing list.
.It Ic dump-database
Dumps database data to STDOUT.
.It Ic list-lists
Lists all registered mailing lists.
.It Ic list
Mailing list management.
.It Ic create-list
Create new list.
.It Ic post
Post message from STDIN to list.
.It Ic error-queue
Mail that has not been handled properly end up in the error queue.
.It Ic import-maildir
Import a maildir folder into an existing list.
.El
.Pp

View File

@ -0,0 +1,28 @@
.Bl -tag -width Ds -compact -offset indent
.It Ic list
List.
.It Ic print
.Fl -index Ar index ...
index of entry.
.Fl -json
JSON format.
Print entry in RFC5322 or JSON format.
.It Ic delete
.Fl -index Ar index ...
index of entry.
.Fl -quiet
Do not print in stdout.
Delete entry and print it in stdout.
.It Ic list
List.
.It Ic print
Print entry in RFC5322 or JSON format.
.It Ic delete
Delete entry and print it in stdout.
.El
.Pp

2
docs/footer.mdoc 100644
View File

@ -0,0 +1,2 @@
.Sh AUTHORS
Manos Pitsidianakis <epilys@nessuent.xyz>

6
docs/header.mdoc 100644
View File

@ -0,0 +1,6 @@
.Dd $Mdocdate$
.Dt MAILPOT 1
.Os
.Sh NAME
.Nm mailpot
.Nd mini mailing list manager.

110
docs/list.mdoc 100644
View File

@ -0,0 +1,110 @@
.Bl -tag -width Ds -compact -offset indent
.It Ic members
List members of list.
.It Ic add-member
.Fl -address Ar address
E-mail address.
.Fl -name Ar name
Name.
.Fl -digest
Send messages as digest?.
.Fl -hide-address
Hide message from list when posting?.
.Fl -receive-confirmation Ar receive-confirmation
Hide message from list when posting? Receive confirmation email when posting?.
.Fl -receive-duplicates Ar receive-duplicates
Receive posts from list even if address exists in To or Cc header?.
.Fl -receive-own-posts Ar receive-own-posts
Receive own posts from list?.
.Fl -enabled Ar enabled
Is subscription enabled?.
Add member to list.
.It Ic remove-member
.Fl -address Ar address
E-mail address.
Remove member from list.
.It Ic update-membership
Update membership info.
.It Ic add-policy
.Fl -announce-only
.
.Fl -subscriber-only
.
.Fl -approval-needed
.
.Fl -no-subscriptions
.
.Fl -custom
.
Add policy to list.
.It Ic remove-policy
.Fl -pk Ar pk
.
.
.It Ic add-list-owner
.Fl -address Ar address
.
.Fl -name Ar name
.
Add list owner to list.
.It Ic remove-list-owner
.Fl -pk Ar pk
.
.
.It Ic enable-membership
Alias for update-membership --enabled true.
.It Ic disable-membership
Alias for update-membership --enabled false.
.It Ic update
Update mailing list details.
.It Ic health
Show mailing list health status.
.It Ic info
Show mailing list info.
.It Ic members
List members of list.
.It Ic add-member
Add member to list.
.It Ic remove-member
Remove member from list.
.It Ic update-membership
Update membership info.
.It Ic add-policy
Add policy to list.
.It Ic remove-policy
.
.It Ic add-list-owner
Add list owner to list.
.It Ic remove-list-owner
.
.It Ic enable-membership
Alias for update-membership --enabled true.
.It Ic disable-membership
Alias for update-membership --enabled false.
.It Ic update
Update mailing list details.
.It Ic health
Show mailing list health status.
.It Ic info
Show mailing list info.
.El
.Pp

229
docs/mailpot.1 100644
View File

@ -0,0 +1,229 @@
.Dd $Mdocdate$
.Dt MAILPOT 1
.Os
.Sh NAME
.Nm mailpot
.Nd mini mailing list manager.
.Sh SYNOPSIS
.Nm
.Op Fl -debug
.Op Fl -config Ar config
.Op Fl -quiet | -q
.Op Fl -verbose | -v
.Op Fl -timestamp | -t Ar timestamp
.Bl -tag -width flag -offset indent
.It Fl -debug
Activate debug mode.
.It Fl -config Ar config
Set config file.
.It Fl -quiet | -q
Silence all output.
.It Fl -verbose | -v
Verbose mode (-v, -vv, -vvv, etc).
.It Fl -timestamp | -t Ar timestamp
Timestamp (sec, ms, ns, none).
.El
.Sh DESCRIPTION
This command-line tool allows you to control databases of the mailing list manager
.Nm Ns .
.Pp
.Sh COMMANDS
.Bl -tag -width Ds -compact -offset indent
.It Ic dump-database
Dumps database data to STDOUT.
.It Ic list-lists
Lists all registered mailing lists.
.It Ic list
Mailing list management.
.It Ic create-list
.Fl -name Ar name
List name.
.Fl -id Ar id
List ID.
.Fl -address Ar address
List e-mail address.
.Fl -description Ar description
List description.
.Fl -archive-url Ar archive-url
List archive URL.
Create new list.
.It Ic post
.Fl -dry-run
.
Post message from STDIN to list.
.It Ic error-queue
Mail that has not been handled properly end up in the error queue.
.It Ic import-maildir
.Fl -maildir-path Ar maildir-path
.
Import a maildir folder into an existing list.
.It Ic dump-database
Dumps database data to STDOUT.
.It Ic list-lists
Lists all registered mailing lists.
.It Ic list
Mailing list management.
.It Ic create-list
Create new list.
.It Ic post
Post message from STDIN to list.
.It Ic error-queue
Mail that has not been handled properly end up in the error queue.
.It Ic import-maildir
Import a maildir folder into an existing list.
.El
.Pp
.Ss list subcommands
.Bl -tag -width Ds -compact -offset indent
.It Ic members
List members of list.
.It Ic add-member
.Fl -address Ar address
E-mail address.
.Fl -name Ar name
Name.
.Fl -digest
Send messages as digest?.
.Fl -hide-address
Hide message from list when posting?.
.Fl -receive-confirmation Ar receive-confirmation
Hide message from list when posting? Receive confirmation email when posting?.
.Fl -receive-duplicates Ar receive-duplicates
Receive posts from list even if address exists in To or Cc header?.
.Fl -receive-own-posts Ar receive-own-posts
Receive own posts from list?.
.Fl -enabled Ar enabled
Is subscription enabled?.
Add member to list.
.It Ic remove-member
.Fl -address Ar address
E-mail address.
Remove member from list.
.It Ic update-membership
Update membership info.
.It Ic add-policy
.Fl -announce-only
.
.Fl -subscriber-only
.
.Fl -approval-needed
.
.Fl -no-subscriptions
.
.Fl -custom
.
Add policy to list.
.It Ic remove-policy
.Fl -pk Ar pk
.
.
.It Ic add-list-owner
.Fl -address Ar address
.
.Fl -name Ar name
.
Add list owner to list.
.It Ic remove-list-owner
.Fl -pk Ar pk
.
.
.It Ic enable-membership
Alias for update-membership --enabled true.
.It Ic disable-membership
Alias for update-membership --enabled false.
.It Ic update
Update mailing list details.
.It Ic health
Show mailing list health status.
.It Ic info
Show mailing list info.
.It Ic members
List members of list.
.It Ic add-member
Add member to list.
.It Ic remove-member
Remove member from list.
.It Ic update-membership
Update membership info.
.It Ic add-policy
Add policy to list.
.It Ic remove-policy
.
.It Ic add-list-owner
Add list owner to list.
.It Ic remove-list-owner
.
.It Ic enable-membership
Alias for update-membership --enabled true.
.It Ic disable-membership
Alias for update-membership --enabled false.
.It Ic update
Update mailing list details.
.It Ic health
Show mailing list health status.
.It Ic info
Show mailing list info.
.El
.Pp
.Ss error-queue subcommands
.Bl -tag -width Ds -compact -offset indent
.It Ic list
List.
.It Ic print
.Fl -index Ar index ...
index of entry.
.Fl -json
JSON format.
Print entry in RFC5322 or JSON format.
.It Ic delete
.Fl -index Ar index ...
index of entry.
.Fl -quiet
Do not print in stdout.
Delete entry and print it in stdout.
.It Ic list
List.
.It Ic print
Print entry in RFC5322 or JSON format.
.It Ic delete
Delete entry and print it in stdout.
.El
.Pp
.Sh AUTHORS
Manos Pitsidianakis <epilys@nessuent.xyz>

14
docs/mailpot.1.m4 100644
View File

@ -0,0 +1,14 @@
include(`docs/header.mdoc')
.Sh SYNOPSIS
include(`docs/main.mdoc')
.Sh DESCRIPTION
This command-line tool allows you to control databases of the mailing list manager
.Nm Ns .
.Pp
.Sh COMMANDS
include(`docs/command.mdoc')
.Ss list subcommands
include(`docs/list.mdoc')
.Ss error-queue subcommands
include(`docs/error_queue.mdoc')
include(`docs/footer.mdoc')

18
docs/main.mdoc 100644
View File

@ -0,0 +1,18 @@
.Nm
.Op Fl -debug
.Op Fl -config Ar config
.Op Fl -quiet | -q
.Op Fl -verbose | -v
.Op Fl -timestamp | -t Ar timestamp
.Bl -tag -width flag -offset indent
.It Fl -debug
Activate debug mode.
.It Fl -config Ar config
Set config file.
.It Fl -quiet | -q
Silence all output.
.It Fl -verbose | -v
Verbose mode (-v, -vv, -vvv, etc).
.It Fl -timestamp | -t Ar timestamp
Timestamp (sec, ms, ns, none).
.El

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
# Relevant RFCs
The documents are gziped, to read them either do `gunzip GZ_FILE` or use a gzip compatible reader-editor, like vim (see `:help gzip`):
1. open vim
2. `:set loaded_gzip = 1`
3. `:e GZ_FILE`

View File

@ -1,79 +0,0 @@
## Search RFCs
[Advanced Search](/search/rfc_search.php)
# [RFC Editor](https://www.rfc-editor.org/)
#
## [RFC 1153](https://www.rfc-editor.org/rfc/rfc1153.txt)
### Digest message format, April 1990
**File formats:** [![icon for text
file](/rfcscripts/images/RFC_ICONS_Text_40x50.png)](https://www.rfc-
editor.org/rfc/rfc1153.txt) [![icon for
PDF](/rfcscripts/images/RFC_ICONS_PDF_2019_50x40.png)](https://www.rfc-
editor.org/rfc/pdfrfc/rfc1153.txt.pdf) [![icon for
HTML](/rfcscripts/images/RFC_ICONS_HTML_40x50.png)](https://www.rfc-
editor.org/rfc/rfc1153.html)
**Status:** EXPERIMENTAL
**Author:** F.J. Wancho
**Stream:** [Legacy]
**Cite this RFC** : [TXT](/refs/ref1153.txt) |**
[XML](https://bib.ietf.org/public/rfc/bibxml/reference.RFC.1153.xml) |
[BibTeX](https://datatracker.ietf.org/doc/rfc1153/bibtex/)
**DOI** : 10.17487/RFC1153
**Discuss this RFC** : Send questions or comments to the mailing list
[iesg@ietf.org](mailto:iesg@ietf.org?subject=Question regarding RFC 1153 )
**Other actions** : [Submit Errata](/errata.php#reportnew) |** [ Find IPR
Disclosures from the
IETF](https://datatracker.ietf.org/ipr/search/?draft=&rfc=1153&submit=rfc)
|** [ View History of RFC 1153](https://datatracker.ietf.org/doc/rfc1153/)
* * *
## Abstract
This memo describes the de facto standard Digest Message Format. This is an
elective experimental protocol.
* * *
For the definition of **Status** , see [RFC 2026](/info/rfc2026).
For the definition of **Stream** , see [RFC 8729](/info/rfc8729).
* * *
[IAB](//www.iab.org/) • [IANA](//www.iana.org/) • [IETF](//www.ietf.org) •
[IRTF](//www.irtf.org) • [ISE](/about/independent) •
[ISOC](//www.internetsociety.org) • [IETF Trust](//trustee.ietf.org/)
[Reports](/report-summary) • [Privacy Statement](//www.ietf.org/privacy-
statement/) • [Site Map](/sitemap) • [Contact Us](/contact)
[ ]()
* [Document Retrieval](https://www.rfc-editor.org/retrieve/)
* [Errata](/errata.php)
* [Frequently Asked Questions](https://www.rfc-editor.org/faq/)
* [Future Format FAQ](https://www.rfc-editor.org/rse/format-faq/)
* [History](https://www.rfc-editor.org/history/)
* [About Us](https://www.rfc-editor.org/about/)
* [Other Information](https://www.rfc-editor.org/other/)
* [Publication Process](https://www.rfc-editor.org/pubprocess/)
* [Publication Queue](/current_queue.php)
* [Style Guide](https://www.rfc-editor.org/styleguide/)
[Advanced Search](/search/rfc_search.php)

View File

@ -1,92 +0,0 @@
## Search RFCs
[Advanced Search](/search/rfc_search.php)
# [RFC Editor](https://www.rfc-editor.org/)
#
## [RFC 2046](https://www.rfc-editor.org/rfc/rfc2046.txt)
### Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types,
November 1996
**File formats:** [![icon for text
file](/rfcscripts/images/RFC_ICONS_Text_40x50.png)](https://www.rfc-
editor.org/rfc/rfc2046.txt) [![icon for
PDF](/rfcscripts/images/RFC_ICONS_PDF_2019_50x40.png)](https://www.rfc-
editor.org/rfc/pdfrfc/rfc2046.txt.pdf) [![icon for
HTML](/rfcscripts/images/RFC_ICONS_HTML_40x50.png)](https://www.rfc-
editor.org/rfc/rfc2046.html) [![icon for inline
errata](/rfcscripts/images/HTML_correction_40x50_2020.png)](https://www.rfc-
editor.org/rfc/inline-errata/rfc2046.html)
**Status:** DRAFT STANDARD
**Obsoletes:** [RFC 1521](/info/rfc1521), [RFC 1522](/info/rfc1522), [RFC
1590](/info/rfc1590)
**Updated by:** [RFC 2646](/info/rfc2646), [RFC 3798](/info/rfc3798), [RFC
5147](/info/rfc5147), [RFC 6657](/info/rfc6657), [RFC 8098](/info/rfc8098)
**Authors:** N. Freed
N. Borenstein
**Stream:** [IETF](https://www.ietf.org)
**Source:** [822ext](//datatracker.ietf.org/wg/822ext/about/)
([app](//datatracker.ietf.org/wg/#app))
**Cite this RFC** : [TXT](/refs/ref2046.txt) |**
[XML](https://bib.ietf.org/public/rfc/bibxml/reference.RFC.2046.xml) |
[BibTeX](https://datatracker.ietf.org/doc/rfc2046/bibtex/)
**DOI** : 10.17487/RFC2046
**Discuss this RFC** : Send questions or comments to the mailing list
[iesg@ietf.org](mailto:iesg@ietf.org?subject=Question regarding RFC 2046 )
**Other actions** : [View Errata](/errata/rfc2046) |** [Submit
Errata](/errata.php#reportnew) |** [ Find IPR Disclosures from the
IETF](https://datatracker.ietf.org/ipr/search/?draft=&rfc=2046&submit=rfc)
|** [ View History of RFC 2046](https://datatracker.ietf.org/doc/rfc2046/)
* * *
## Abstract
This second document defines the general structure of the MIME media typing
system and defines an initial set of media types. [STANDARDS-TRACK]
* * *
For the definition of **Status** , see [RFC 2026](/info/rfc2026).
For the definition of **Stream** , see [RFC 8729](/info/rfc8729).
* * *
[IAB](//www.iab.org/) • [IANA](//www.iana.org/) • [IETF](//www.ietf.org) •
[IRTF](//www.irtf.org) • [ISE](/about/independent) •
[ISOC](//www.internetsociety.org) • [IETF Trust](//trustee.ietf.org/)
[Reports](/report-summary) • [Privacy Statement](//www.ietf.org/privacy-
statement/) • [Site Map](/sitemap) • [Contact Us](/contact)
[ ]()
* [Document Retrieval](https://www.rfc-editor.org/retrieve/)
* [Errata](/errata.php)
* [Frequently Asked Questions](https://www.rfc-editor.org/faq/)
* [Future Format FAQ](https://www.rfc-editor.org/rse/format-faq/)
* [History](https://www.rfc-editor.org/history/)
* [About Us](https://www.rfc-editor.org/about/)
* [Other Information](https://www.rfc-editor.org/other/)
* [Publication Process](https://www.rfc-editor.org/pubprocess/)
* [Publication Queue](/current_queue.php)
* [Style Guide](https://www.rfc-editor.org/styleguide/)
[Advanced Search](/search/rfc_search.php)

View File

@ -1,87 +0,0 @@
## Search RFCs
[Advanced Search](/search/rfc_search.php)
# [RFC Editor](https://www.rfc-editor.org/)
#
## [RFC 2369](https://www.rfc-editor.org/rfc/rfc2369.txt)
### The Use of URLs as Meta-Syntax for Core Mail List Commands and their
Transport through Message Header Fields, July 1998
**File formats:** [![icon for text
file](/rfcscripts/images/RFC_ICONS_Text_40x50.png)](https://www.rfc-
editor.org/rfc/rfc2369.txt) [![icon for
PDF](/rfcscripts/images/RFC_ICONS_PDF_2019_50x40.png)](https://www.rfc-
editor.org/rfc/pdfrfc/rfc2369.txt.pdf) [![icon for
HTML](/rfcscripts/images/RFC_ICONS_HTML_40x50.png)](https://www.rfc-
editor.org/rfc/rfc2369.html)
**Status:** PROPOSED STANDARD
**Authors:** G. Neufeld
J. Baer
**Stream:** [Legacy]
**Cite this RFC** : [TXT](/refs/ref2369.txt) |**
[XML](https://bib.ietf.org/public/rfc/bibxml/reference.RFC.2369.xml) |
[BibTeX](https://datatracker.ietf.org/doc/rfc2369/bibtex/)
**DOI** : 10.17487/RFC2369
**Discuss this RFC** : Send questions or comments to the mailing list
[iesg@ietf.org](mailto:iesg@ietf.org?subject=Question regarding RFC 2369 )
**Other actions** : [Submit Errata](/errata.php#reportnew) |** [ Find IPR
Disclosures from the
IETF](https://datatracker.ietf.org/ipr/search/?draft=&rfc=2369&submit=rfc)
|** [ View History of RFC 2369](https://datatracker.ietf.org/doc/rfc2369/)
* * *
## Abstract
The mailing list command specification header fields are a set of structured
fields to be added to email messages sent by email distribution lists. By
including these header fields, list servers can make it possible for mail
clients to provide automated tools for users to perform list functions. This
could take the form of a menu item, push button, or other user interface
element. The intent is to simplify the user experience, providing a common
interface to the often cryptic and varied mailing list manager commands.
[STANDARDS-TRACK]
* * *
For the definition of **Status** , see [RFC 2026](/info/rfc2026).
For the definition of **Stream** , see [RFC 8729](/info/rfc8729).
* * *
[IAB](//www.iab.org/) • [IANA](//www.iana.org/) • [IETF](//www.ietf.org) •
[IRTF](//www.irtf.org) • [ISE](/about/independent) •
[ISOC](//www.internetsociety.org) • [IETF Trust](//trustee.ietf.org/)
[Reports](/report-summary) • [Privacy Statement](//www.ietf.org/privacy-
statement/) • [Site Map](/sitemap) • [Contact Us](/contact)
[ ]()
* [Document Retrieval](https://www.rfc-editor.org/retrieve/)
* [Errata](/errata.php)
* [Frequently Asked Questions](https://www.rfc-editor.org/faq/)
* [Future Format FAQ](https://www.rfc-editor.org/rse/format-faq/)
* [History](https://www.rfc-editor.org/history/)
* [About Us](https://www.rfc-editor.org/about/)
* [Other Information](https://www.rfc-editor.org/other/)
* [Publication Process](https://www.rfc-editor.org/pubprocess/)
* [Publication Queue](/current_queue.php)
* [Style Guide](https://www.rfc-editor.org/styleguide/)
[Advanced Search](/search/rfc_search.php)

View File

@ -1,90 +0,0 @@
## Search RFCs
[Advanced Search](/search/rfc_search.php)
# [RFC Editor](https://www.rfc-editor.org/)
#
## [RFC 2919](https://www.rfc-editor.org/rfc/rfc2919.txt)
### List-Id: A Structured Field and Namespace for the Identification of
Mailing Lists, March 2001
**File formats:** [![icon for text
file](/rfcscripts/images/RFC_ICONS_Text_40x50.png)](https://www.rfc-
editor.org/rfc/rfc2919.txt) [![icon for
PDF](/rfcscripts/images/RFC_ICONS_PDF_2019_50x40.png)](https://www.rfc-
editor.org/rfc/pdfrfc/rfc2919.txt.pdf) [![icon for
HTML](/rfcscripts/images/RFC_ICONS_HTML_40x50.png)](https://www.rfc-
editor.org/rfc/rfc2919.html) [![icon for inline
errata](/rfcscripts/images/HTML_correction_40x50_2020.png)](https://www.rfc-
editor.org/rfc/inline-errata/rfc2919.html)
**Status:** PROPOSED STANDARD
**Authors:** R. Chandhok
G. Wenger
**Stream:** [IETF](https://www.ietf.org)
**Source:** [NON WORKING GROUP](https://www.ietf.org/blog/guidance-area-
director-sponsoring-documents)
**Cite this RFC** : [TXT](/refs/ref2919.txt) |**
[XML](https://bib.ietf.org/public/rfc/bibxml/reference.RFC.2919.xml) |
[BibTeX](https://datatracker.ietf.org/doc/rfc2919/bibtex/)
**DOI** : 10.17487/RFC2919
**Discuss this RFC** : Send questions or comments to the mailing list
[iesg@ietf.org](mailto:iesg@ietf.org?subject=Question regarding RFC 2919 )
**Other actions** : [View Errata](/errata/rfc2919) |** [Submit
Errata](/errata.php#reportnew) |** [ Find IPR Disclosures from the
IETF](https://datatracker.ietf.org/ipr/search/?draft=&rfc=2919&submit=rfc)
|** [ View History of RFC 2919](https://datatracker.ietf.org/doc/rfc2919/)
* * *
## Abstract
Software that handles electronic mailing list messages (servers and user
agents) needs a way to reliably identify messages that belong to a particular
mailing list. With the advent of list management headers, it has become even
more important to provide a unique identifier for a mailing list regardless of
the particular host that serves as the list processor at any given time.
[STANDARDS-TRACK]
* * *
For the definition of **Status** , see [RFC 2026](/info/rfc2026).
For the definition of **Stream** , see [RFC 8729](/info/rfc8729).
* * *
[IAB](//www.iab.org/) • [IANA](//www.iana.org/) • [IETF](//www.ietf.org) •
[IRTF](//www.irtf.org) • [ISE](/about/independent) •
[ISOC](//www.internetsociety.org) • [IETF Trust](//trustee.ietf.org/)
[Reports](/report-summary) • [Privacy Statement](//www.ietf.org/privacy-
statement/) • [Site Map](/sitemap) • [Contact Us](/contact)
[ ]()
* [Document Retrieval](https://www.rfc-editor.org/retrieve/)
* [Errata](/errata.php)
* [Frequently Asked Questions](https://www.rfc-editor.org/faq/)
* [Future Format FAQ](https://www.rfc-editor.org/rse/format-faq/)
* [History](https://www.rfc-editor.org/history/)
* [About Us](https://www.rfc-editor.org/about/)
* [Other Information](https://www.rfc-editor.org/other/)
* [Publication Process](https://www.rfc-editor.org/pubprocess/)
* [Publication Queue](/current_queue.php)
* [Style Guide](https://www.rfc-editor.org/styleguide/)
[Advanced Search](/search/rfc_search.php)

View File

@ -1,77 +0,0 @@
# Ideas, plans, thoughts on `mailpot`.
It'd be better if this stuff wasn't on an issue tracker like gitea's or
github's but committed in the repository.
Discussion about these notes can take place in the mailing list,
[`<mailpot-general@meli.delivery>`](https://lists.meli.delivery/list/mailpot-general/).
In no particular order:
**Table of contents**:
* [Possible Postfix integrations](#possible-postfix-integrations)
* [Setup docker container network with postfix for testing](#setup-docker-container-network-with-postfix-for-testing)
* [Add NNTP gateways](#add-nntp-gateways)
* [Add MIME type filter for list owners](#add-mime-type-filter-for-list-owners)
* [Add `convert_html_to_plaintext` filter](#add-convert_html_to_plaintext-filter)
* [Use mdoc instead of roff for manpages](#use-mdoc-instead-of-roff-for-manpages)
* [Add shell completions with `clap`](#add-shell-completions-with-clap)
* [Make complex database logic and/or complex migrations with user defined functions](#make-complex-database-logic-andor-complex-migrations-with-user-defined-functions)
* [Implement dtolnay's mailing set concept](#implement-dtolnays-mailing-set-concept)
## Possible Postfix integrations
- local delivery with `postdrop(1)` instead of SMTP
- log with `postlog(1)`
- sqlite maps <https://www.postfix.org/SQLITE_README.html>
## Setup docker container network with postfix for testing
Beyond integration tests, we need a real-world testcase: a bunch of user postfixes talking to a mailing list postfix.
This can be done with a docker setup.
A simple debian slim image can be used for this.
Reference for postfix on docker: <https://www.frakkingsweet.com/postfix-in-a-container/>.
It'd be great if we could use a Rust based solution as well, with something like <https://github.com/fussybeaver/bollard>.
## Add NNTP gateways
TODO
## Add MIME type filter for list owners
TODO
## Add `convert_html_to_plaintext` filter
TODO
## Use mdoc instead of roff for manpages
[`mdoc` reference](https://man.openbsd.org/mdoc.7)
Progress:
- Got ownership of `mdoc` on crates.io.
- Forked `roff` crate to use as a basis: <https://github.com/epilys/mdoc>
## Add shell completions with `clap`
Probably with <https://docs.rs/clap_complete/latest/clap_complete/>
## Make complex database logic and/or complex migrations with user defined functions
Useful projects:
- <https://github.com/facebookincubator/CG-SQL/tree/main>
- <https://github.com/epilys/vfsstat.rs>
## Implement dtolnay's mailing set concept
See <https://github.com/dtolnay/mailingset/tree/master>
> A mailing list server that treates mailing lists as sets and allows mail to
> be sent to the result of set-algebraic expressions on those sets. The union,
> intersection, and difference operators are supported. Sending mail to a set
> operation involves specifying a set expression in the local part of the
> recipient email address.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,39 +0,0 @@
[package]
name = "mailpot-cli"
version = "0.1.1"
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
edition = "2021"
license = "LICENSE"
readme = "README.md"
description = "mailing list manager"
repository = "https://github.com/meli/mailpot"
keywords = ["mail", "mailing-lists"]
categories = ["email"]
default-run = "mpot"
[[bin]]
name = "mpot"
path = "src/main.rs"
doc-scrape-examples = true
[dependencies]
base64 = { version = "0.21" }
clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] }
log = "0.4"
mailpot = { version = "^0.1", path = "../mailpot" }
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 = "../mailpot" }
stderrlog = { version = "^0.6" }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
.env
config/local.json

View File

@ -1,49 +0,0 @@
[package]
name = "mailpot-http"
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-http"
[[bin]]
name = "mpot-http"
path = "src/main.rs"
[dependencies]
async-trait = "0.1"
axum = { version = "0.6", features = ["headers"] }
axum-extra = { version = "^0.7", features = ["typed-routing"] }
#jsonwebtoken = "8.3"
bcrypt = "0.14"
config = "0.13"
http = "0.2"
lazy_static = "1.4"
log = "0.4"
mailpot = { version = "^0.1", path = "../mailpot" }
mailpot-web = { version = "^0.1", path = "../mailpot-web" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
stderrlog = { version = "^0.6" }
thiserror = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.4", features = [
"trace",
"compression-br",
"propagate-header",
"sensitive-headers",
"cors",
] }
[dev-dependencies]
assert-json-diff = "2"
hyper = { version = "0.14" }
mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
reqwest = { version = "0.11", features = ["json"] }
tempfile = { version = "3.9" }
tower = { version = "^0.4" }

View File

@ -1,2 +0,0 @@
# mailpot REST http server

View File

@ -1,12 +0,0 @@
{
"environment": "development",
"server": {
"port": 8080
},
"auth": {
"secret": "secret"
},
"logger": {
"level": "debug"
}
}

View File

@ -1,6 +0,0 @@
{
"environment": "production",
"logger": {
"level": "info"
}
}

View File

@ -1,9 +0,0 @@
{
"environment": "test",
"server": {
"port": 8088
},
"logger": {
"level": "error"
}
}

View File

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

View File

@ -1,98 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use bcrypt::BcryptError;
use serde_json::json;
use tokio::task::JoinError;
#[derive(thiserror::Error, Debug)]
#[error("...")]
pub enum Error {
#[error("Error parsing ObjectID {0}")]
ParseObjectID(String),
#[error("{0}")]
Authenticate(#[from] AuthenticateError),
#[error("{0}")]
BadRequest(#[from] BadRequest),
#[error("{0}")]
NotFound(#[from] NotFound),
#[error("{0}")]
RunSyncTask(#[from] JoinError),
#[error("{0}")]
HashPassword(#[from] BcryptError),
#[error("{0}")]
System(#[from] mailpot::Error),
}
impl Error {
fn get_codes(&self) -> (StatusCode, u16) {
match *self {
// 4XX Errors
Error::ParseObjectID(_) => (StatusCode::BAD_REQUEST, 40001),
Error::BadRequest(_) => (StatusCode::BAD_REQUEST, 40002),
Error::NotFound(_) => (StatusCode::NOT_FOUND, 40003),
Error::Authenticate(AuthenticateError::WrongCredentials) => {
(StatusCode::UNAUTHORIZED, 40004)
}
Error::Authenticate(AuthenticateError::InvalidToken) => {
(StatusCode::UNAUTHORIZED, 40005)
}
Error::Authenticate(AuthenticateError::Locked) => (StatusCode::LOCKED, 40006),
// 5XX Errors
Error::Authenticate(AuthenticateError::TokenCreation) => {
(StatusCode::INTERNAL_SERVER_ERROR, 5001)
}
Error::RunSyncTask(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5005),
Error::HashPassword(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5006),
Error::System(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5007),
}
}
pub fn bad_request() -> Self {
Error::BadRequest(BadRequest {})
}
pub fn not_found() -> Self {
Error::NotFound(NotFound {})
}
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let (status_code, code) = self.get_codes();
let message = self.to_string();
let body = Json(json!({ "code": code, "message": message }));
(status_code, body).into_response()
}
}
#[derive(thiserror::Error, Debug)]
#[error("...")]
pub enum AuthenticateError {
#[error("Wrong authentication credentials")]
WrongCredentials,
#[error("Failed to create authentication token")]
TokenCreation,
#[error("Invalid authentication credentials")]
InvalidToken,
#[error("User is locked")]
Locked,
}
#[derive(thiserror::Error, Debug)]
#[error("Bad Request")]
pub struct BadRequest {}
#[derive(thiserror::Error, Debug)]
#[error("Not found")]
pub struct NotFound {}

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