mirror of
https://github.com/alwinber/mailboxrelay.git
synced 2025-12-06 02:15:53 +00:00
Init
This commit is contained in:
commit
ef7a2d3e00
11 changed files with 2670 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
.direnv
|
||||||
2298
Cargo.lock
generated
Normal file
2298
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "mailboxrelay"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Alwin Berger <alwin.berger@udo.edu>"]
|
||||||
|
categories = ["email"]
|
||||||
|
license = "0BSD"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = "1.0.209"
|
||||||
|
toml = "0.8.19"
|
||||||
|
clap = { version = "4.5.16", features = ["derive"] }
|
||||||
|
imap = "2.4.1"
|
||||||
|
mail-parser = "0.9.4"
|
||||||
|
mail-send = "0.4.9"
|
||||||
|
native-tls = "0.2.12"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
12
LICENSE.md
Normal file
12
LICENSE.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
Copyright (C) 2024 by Alwin Berger <alwin.berger@udo.edu>
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||||
|
with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
|
||||||
|
OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Mailrelay
|
||||||
|
A simple mail retrieval agent that retrieves and forwards your mail to a different address.
|
||||||
|
This is usefull when you want to migrate away from a mail provider that charges for automatic forwarding.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```
|
||||||
|
$ mailrelay -h
|
||||||
|
A mail retrieval agent that retrieves email using IMAP and forwards it to a different address using SMTP
|
||||||
|
|
||||||
|
Usage: mailrelay [OPTIONS] --config <CONFIG>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-c, --config <CONFIG> Path to the config file with login information
|
||||||
|
-i, --interval <INTERVAL> The interval in seconds to check for new emails. Use 0 for oneshot [default: 600]
|
||||||
|
-h, --help Print help
|
||||||
|
-V, --version Print version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
Config files look lile this:
|
||||||
|
```toml
|
||||||
|
[someaccount]
|
||||||
|
imap_domain = "imap.example.com"
|
||||||
|
imap_username = "user@example.com"
|
||||||
|
imap_password = "p4ssw0rd"
|
||||||
|
smtp_domain = "smtp.example.com"
|
||||||
|
smtp_username = "user@example.com"
|
||||||
|
smtp_password = "p4ssw0rd"
|
||||||
|
mailboxes = ["INBOX", "Junk"]
|
||||||
|
forward_target = "you@yourdomain.tld"
|
||||||
|
```
|
||||||
7
default.nix
Normal file
7
default.nix
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
(import (
|
||||||
|
fetchTarball {
|
||||||
|
url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz";
|
||||||
|
sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; }
|
||||||
|
) {
|
||||||
|
src = ./.;
|
||||||
|
}).defaultNix
|
||||||
95
flake.lock
Normal file
95
flake.lock
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"naersk": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1721727458,
|
||||||
|
"narHash": "sha256-r/xppY958gmZ4oTfLiHN0ZGuQ+RSTijDblVgVLFi1mw=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "naersk",
|
||||||
|
"rev": "3fb418eaf352498f6b6c30592e3beb63df42ef11",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"ref": "master",
|
||||||
|
"repo": "naersk",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1725194671,
|
||||||
|
"narHash": "sha256-tLGCFEFTB5TaOKkpfw3iYT9dnk4awTP/q4w+ROpMfuw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b833ff01a0d694b910daca6e2ff4a3f26dee478c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "nixpkgs",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1725194671,
|
||||||
|
"narHash": "sha256-tLGCFEFTB5TaOKkpfw3iYT9dnk4awTP/q4w+ROpMfuw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b833ff01a0d694b910daca6e2ff4a3f26dee478c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"naersk": "naersk",
|
||||||
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"utils": "utils"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1710146030,
|
||||||
|
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
26
flake.nix
Normal file
26
flake.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
naersk.url = "github:nix-community/naersk/master";
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, utils, naersk }:
|
||||||
|
utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
naersk-lib = pkgs.callPackage naersk { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
defaultPackage = naersk-lib.buildPackage {src = ./.; buildInputs = [ pkgs.openssl pkgs.bzip2 ];};
|
||||||
|
devShell = with pkgs; mkShell rec {
|
||||||
|
buildInputs = [ cargo rustc rustfmt pre-commit rustPackages.clippy zlib openssl bzip2 ];
|
||||||
|
RUST_SRC_PATH = rustPlatform.rustLibSrc;
|
||||||
|
shellHook = ''
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH"
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib.outPath}/lib:$LD_LIBRARY_PATH"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
7
shell.nix
Normal file
7
shell.nix
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
(import (
|
||||||
|
fetchTarball {
|
||||||
|
url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz";
|
||||||
|
sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; }
|
||||||
|
) {
|
||||||
|
src = ./.;
|
||||||
|
}).shellNix
|
||||||
174
src/main.rs
Normal file
174
src/main.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
use clap::Parser;
|
||||||
|
use imap::types::Fetch;
|
||||||
|
use imap::types::ZeroCopy;
|
||||||
|
use mail_parser::*;
|
||||||
|
use mail_send::SmtpClientBuilder;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::{error::Error, net::TcpStream};
|
||||||
|
use std::{thread, time};
|
||||||
|
|
||||||
|
/// A mail retrieval agent that retrieves email using IMAP and forwards it to a different address using SMTP.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to the config file with login information
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: String,
|
||||||
|
|
||||||
|
/// The interval in seconds to check for new emails. Use 0 for oneshot.
|
||||||
|
#[arg(short, long, default_value = "600")]
|
||||||
|
interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Default, Debug, Hash)]
|
||||||
|
struct Config {
|
||||||
|
imap_domain: String,
|
||||||
|
imap_username: String,
|
||||||
|
imap_password: String,
|
||||||
|
smtp_domain: String,
|
||||||
|
smtp_username: String,
|
||||||
|
smtp_password: String,
|
||||||
|
mailboxes: Vec<String>,
|
||||||
|
forward_target: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_session(
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
imap::Session<native_tls::TlsStream<TcpStream>>,
|
||||||
|
native_tls::TlsConnector,
|
||||||
|
),
|
||||||
|
Box<dyn Error>,
|
||||||
|
> {
|
||||||
|
// Setup Rustls TcpStream
|
||||||
|
let stream = TcpStream::connect((config.imap_domain.as_ref(), 993))?;
|
||||||
|
let tls = native_tls::TlsConnector::builder().build().unwrap();
|
||||||
|
let tlsstream = tls.connect(&config.imap_domain, stream)?;
|
||||||
|
|
||||||
|
// we pass in the domain twice to check that the server's TLS
|
||||||
|
// certificate is valid for the domain we're connecting to.
|
||||||
|
let client = imap::Client::new(tlsstream);
|
||||||
|
|
||||||
|
// the client we have here is unauthenticated.
|
||||||
|
// to do anything useful with the e-mails, we need to log in
|
||||||
|
let imap_session = client
|
||||||
|
.login(&config.imap_username, &config.imap_password)
|
||||||
|
.map_err(|e| e.0)?;
|
||||||
|
|
||||||
|
Ok((imap_session, tls))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_unread_mail(
|
||||||
|
session: &mut imap::Session<native_tls::TlsStream<TcpStream>>,
|
||||||
|
) -> Result<ZeroCopy<Vec<Fetch>>, Box<dyn Error>> {
|
||||||
|
let unseen = session.uid_search("NOT SEEN")?;
|
||||||
|
let unseen_str = unseen
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(",");
|
||||||
|
// session.uid_fetch(&unseen_str, "ALL")?;
|
||||||
|
// TODO: issue a warning for large emails
|
||||||
|
Ok(session.uid_fetch(&unseen_str, "BODY.PEEK[]")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mail(mail: &Fetch) -> Result<Message, Box<dyn Error>> {
|
||||||
|
let body = mail.body().ok_or("could not get body")?;
|
||||||
|
let message = MessageParser::default()
|
||||||
|
.parse(body)
|
||||||
|
.ok_or("could not parse message")?;
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_forward_message<'a>(
|
||||||
|
message: &'a Message,
|
||||||
|
config: &'a Config,
|
||||||
|
) -> mail_send::smtp::message::Message<'a> {
|
||||||
|
mail_send::smtp::message::Message::default()
|
||||||
|
.to(config.forward_target.clone())
|
||||||
|
.from(config.smtp_username.clone())
|
||||||
|
.body(message.raw_message())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_as_seen(
|
||||||
|
session: &mut imap::Session<native_tls::TlsStream<TcpStream>>,
|
||||||
|
fetch: &Fetch,
|
||||||
|
) -> Result<ZeroCopy<Vec<Fetch>>, Box<dyn Error>> {
|
||||||
|
Ok(session.uid_store(
|
||||||
|
fetch.uid.ok_or("uid not found in fetch")?.to_string(),
|
||||||
|
"+FLAGS (\\Seen)",
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn send_mail(
|
||||||
|
message: mail_send::smtp::message::Message,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
// Connect to the SMTP submissions port, upgrade to TLS and
|
||||||
|
// authenticate using the provided credentials.
|
||||||
|
let creds = mail_send::Credentials::Plain {
|
||||||
|
username: &config.smtp_username.to_string(),
|
||||||
|
secret: &config.smtp_password.to_string(),
|
||||||
|
};
|
||||||
|
let mut client = SmtpClientBuilder::new(&config.smtp_domain, 465)
|
||||||
|
.implicit_tls(true)
|
||||||
|
.credentials(creds)
|
||||||
|
.connect()
|
||||||
|
.await?;
|
||||||
|
client.send(message).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_full_cycle(config: &Config) -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut session = open_session(config)?;
|
||||||
|
for mailbox in config.mailboxes.iter() {
|
||||||
|
let info = session.0.select(mailbox)?;
|
||||||
|
if info.unseen.unwrap_or(0) <= 0 {
|
||||||
|
println!("No unseen mails in {mailbox}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mbox = fetch_unread_mail(&mut session.0)?;
|
||||||
|
println!("{} unseen mails in {mailbox}", mbox.len());
|
||||||
|
for mail in mbox.iter() {
|
||||||
|
let message = parse_mail(mail)?;
|
||||||
|
let fwd = build_forward_message(&message, &config);
|
||||||
|
send_mail(fwd, &config)?;
|
||||||
|
mark_as_seen(&mut session.0, mail)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.0.logout()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = Args::parse();
|
||||||
|
let configs = toml::from_str::<HashMap<String, Config>>(
|
||||||
|
&fs::read_to_string(args.config).expect("Could not read config file"),
|
||||||
|
)
|
||||||
|
.expect("Could not parse config file");
|
||||||
|
|
||||||
|
let interval = time::Duration::from_secs(args.interval);
|
||||||
|
if args.interval <= 0 {
|
||||||
|
println!("Running oneshot mode");
|
||||||
|
} else {
|
||||||
|
println!("Starting polling for new mails every {}s", args.interval);
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
for (name, config) in configs.iter() {
|
||||||
|
println!("Processing {name} with {}", config.imap_username);
|
||||||
|
match run_full_cycle(config) {
|
||||||
|
Ok(_) => println!("Successfully processed {name}"),
|
||||||
|
Err(e) => println!("Error processing {name}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args.interval <= 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue