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