This commit is contained in:
Alwin Berger 2024-09-04 18:02:06 +02:00
commit ef7a2d3e00
11 changed files with 2670 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.direnv

2298
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

17
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}