~witcher/rss-email

23d8f3f638c26747996490317d79e2c330ee4c66 — witcher 1 year, 7 months ago 0b00f84 error-refactor
Add custom, application wide error type
M Cargo.lock => Cargo.lock +5 -4
@@ 1176,6 1176,7 @@ dependencies = [
 "rss",
 "serde",
 "sqlx",
 "thiserror",
 "tokio",
 "toml",
]


@@ 1471,18 1472,18 @@ checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb"

[[package]]
name = "thiserror"
version = "1.0.32"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994"
checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e"
dependencies = [
 "thiserror-impl",
]

[[package]]
name = "thiserror-impl"
version = "1.0.32"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21"
checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb"
dependencies = [
 "proc-macro2",
 "quote",

M Cargo.toml => Cargo.toml +1 -0
@@ 24,3 24,4 @@ env_logger = "0.9.0"
tokio = { version = "1.21.2", default-features = false, features = ["rt-multi-thread", "macros"] }
sqlx = { version = "0.6.2", features = ["runtime-tokio-rustls", "migrate", "sqlite", "offline"] }
atom_syndication = "0.11.0"
thiserror = "1.0.37"

M src/cli.rs => src/cli.rs +12 -3
@@ 4,6 4,8 @@ use std::path::PathBuf;

use super::{AUTHORS, VERSION};

use crate::error::Error;

#[derive(Parser)]
#[clap(name = "rss-email")]
#[clap(author = AUTHORS)]


@@ 27,7 29,7 @@ pub struct Cli {
impl Cli {
    /// Parse the clap `Cli` struct with the command line arguments, create the directory for the
    /// configuration files, and create the database if it does not exist yet.
    pub fn build_app() -> anyhow::Result<Self> {
    pub fn build_app() -> Result<Self, Error> {
        use std::fs::{self, File};

        let args = Cli::parse();


@@ 39,7 41,11 @@ impl Cli {
                "Config directory at {:?} does not exist, creating it.",
                c_dir
            );
            fs::create_dir(c_dir)?;
            fs::create_dir(&c_dir).map_err(|e| Error::IO {
                // use lossy string so that the dir can definitely be displayed
                file: c_dir.to_string_lossy().into_owned(),
                reason: e.kind(),
            })?;
        }

        if File::open(&args.database_path).is_err() {


@@ 47,7 53,10 @@ impl Cli {
                "No database file exists, creating a new one: {:?}",
                &args.database_path
            );
            File::create(&args.database_path)?;
            File::create(&args.database_path).map_err(|e| Error::IO {
                file: args.database_path.clone(),
                reason: e.kind(),
            })?;
        }

        Ok(args)

M src/config.rs => src/config.rs +2 -5
@@ 1,4 1,3 @@
use anyhow::Context;
use serde::Deserialize;
use std::fs::File;
use std::{io::Read, path::Path};


@@ 19,11 18,9 @@ pub struct SmtpConfig {
}

impl Config {
    pub fn new(config_path: impl AsRef<Path>) -> anyhow::Result<Config> {
    pub fn new(config_path: impl AsRef<Path>) -> Result<Config, std::io::Error> {
        let mut string = String::new();
        File::open(config_path.as_ref())
            .context(format!("File {:?} does not exist", &config_path.as_ref()))?
            .read_to_string(&mut string)?;
        File::open(config_path.as_ref())?.read_to_string(&mut string)?;
        let config: Config = toml::de::from_str(&string)?;

        Ok(config)

M src/db.rs => src/db.rs +2 -1
@@ 1,10 1,11 @@
use sqlx::pool::PoolConnection;
use sqlx::Sqlite;

use crate::error::Error;
use crate::models::Post;

// inserts a new post or updates an old one with the same guid
pub async fn insert_item(mut conn: PoolConnection<Sqlite>, post: &Post) -> anyhow::Result<()> {
pub async fn insert_item(mut conn: PoolConnection<Sqlite>, post: &Post) -> Result<(), Error> {
    sqlx::query!(
        "insert or ignore into posts (guid, title, url, pub_date, content) values (?, ?, ?, ?, ?)",
        post.guid,

A src/error.rs => src/error.rs +40 -0
@@ 0,0 1,40 @@
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Migration error: {0}")]
    Migrate(#[from] sqlx::migrate::MigrateError),
    #[error("Bad conversion: {0}")]
    Conversion(#[from] ConversionError),
    #[error("Join error: {0}")]
    Join(#[from] tokio::task::JoinError),
    #[error("Invalid feed from URL \"{url}\": {reason}")]
    Feed { url: String, reason: FeedError },
    #[error("Network error: {0}")]
    Network(#[from] reqwest::Error),
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("IO Error on \"{file}\": {reason}")]
    IO {
        file: String,
        reason: std::io::ErrorKind,
    },
    #[error("TOML error while deserializing config file: {0}")]
    TOML(#[from] toml::de::Error),
    #[error("Mail error while sending mail: {0}")]
    Mail(#[from] lettre::transport::smtp::Error),
}

#[derive(Error, Debug)]
pub enum ConversionError {
    #[error("Missing GUID field")]
    MissingGUID,
}

#[derive(Error, Debug)]
pub enum FeedError {
    #[error("Invalid RSS feed: {0}")]
    InvalidRSS(#[from] rss::Error),
    #[error("Invalid Atom feed: {0}")]
    InvalidAtom(#[from] atom_syndication::Error),
}

M src/feed.rs => src/feed.rs +22 -18
@@ 1,20 1,30 @@
use atom_syndication;
use rss;

use crate::anyhow::Context;
use crate::error::{Error, FeedError};
use crate::models::Post;

pub async fn fetch_new<S: AsRef<str>>(url: S) -> anyhow::Result<Vec<Post>> {
pub async fn fetch_new<S: AsRef<str>>(url: S) -> Result<Vec<Post>, Error> {
    debug!("Fetching feed for {}", url.as_ref());
    let content = reqwest::get(url.as_ref()).await?.bytes().await?;
    match fetch_new_rss(&content[..]).await {
        Err(_) => fetch_new_atom(&content[..]).await,
        p => p,
    }
    let url = url.as_ref();
    let content = reqwest::get(url).await?.bytes().await?;

    Ok(match fetch_new_rss(&content[..]).await {
        Err(_) => fetch_new_atom(&content[..])
            .await
            .map_err(|e| Error::Feed {
                url: url.to_string(),
                reason: e,
            })?,
        p => p.map_err(|e| Error::Feed {
            url: url.to_string(),
            reason: e,
        })?,
    })
}

pub async fn fetch_new_rss(bytes: &[u8]) -> anyhow::Result<Vec<Post>> {
    let channel = rss::Channel::read_from(bytes).context("Unable to read from RSS feed")?;
pub async fn fetch_new_rss(bytes: &[u8]) -> Result<Vec<Post>, FeedError> {
    let channel = rss::Channel::read_from(bytes)?;

    Ok(channel
        .items


@@ 29,18 39,12 @@ pub async fn fetch_new_rss(bytes: &[u8]) -> anyhow::Result<Vec<Post>> {
        .collect::<Vec<Post>>())
}

pub async fn fetch_new_atom(bytes: &[u8]) -> anyhow::Result<Vec<Post>> {
    let feed = atom_syndication::Feed::read_from(bytes).context("Unable to read from atom feed")?;
pub async fn fetch_new_atom(bytes: &[u8]) -> Result<Vec<Post>, FeedError> {
    let feed = atom_syndication::Feed::read_from(bytes)?;

    Ok(feed
        .entries
        .into_iter()
        .filter_map(|e| match e.try_into() {
            Ok(e) => Some(e),
            Err(e) => {
                error!("Unable to convert received post, continuing ({e})");
                None
            }
        })
        .map(|e| e.into())
        .collect::<Vec<_>>())
}

M src/mail.rs => src/mail.rs +4 -2
@@ 1,11 1,13 @@
use crate::config::Config;
use lettre::{
    message::Message,
    transport::smtp::{authentication::Credentials, AsyncSmtpTransport},
    AsyncTransport, Tokio1Executor,
};

pub fn get_mailer(config: &Config) -> anyhow::Result<AsyncSmtpTransport<Tokio1Executor>> {
use crate::config::Config;
use crate::error::Error;

pub fn get_mailer(config: &Config) -> Result<AsyncSmtpTransport<Tokio1Executor>, Error> {
    let creds = Credentials::new(
        config.smtp.user.to_string(),
        config.smtp.password.to_string(),

M src/main.rs => src/main.rs +24 -15
@@ 1,25 1,26 @@
#[macro_use]
extern crate log;
#[macro_use]
extern crate anyhow;

pub mod cli;
pub mod config;
pub mod db;
pub mod error;
pub mod feed;
pub mod mail;
pub mod models;

use crate::mail::{get_mailer, send_email};
use anyhow::Context;
use config::Config;
use lettre::{AsyncSmtpTransport, Tokio1Executor};
use std::{
    fs::File,
    io::{BufRead, BufReader},
    sync::Arc,
};

use crate::config::Config;
use crate::error::Error;
use crate::mail::{get_mailer, send_email};

use lettre::{AsyncSmtpTransport, Tokio1Executor};
use sqlx::{sqlite::SqlitePoolOptions, Sqlite};
use tokio::task::JoinSet;



@@ 27,15 28,26 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
const AUTHORS: &str = env!("CARGO_PKG_AUTHORS");

#[tokio::main]
async fn main() -> anyhow::Result<()> {
async fn main() {
    // handling the error manually instead of returning a `Result` from main makes for nicer
    // display of errors to the end user
    if let Err(e) = app_main().await {
        eprintln!("{e}");
    }
}

async fn app_main() -> Result<(), Error> {
    env_logger::init();

    let args = cli::Cli::build_app()?;
    let config = Arc::new(Config::new(args.config_path)?);
    let urls = BufReader::new(
        File::open(args.urls_path.as_str())
            .context(format!("File {:?} does not exist", &args.urls_path))?,
    )
    let config = Arc::new(Config::new(&args.config_path).map_err(|e| Error::IO {
        file: args.config_path,
        reason: e.kind(),
    })?);
    let urls = BufReader::new(File::open(args.urls_path.as_str()).map_err(|e| Error::IO {
        file: args.urls_path,
        reason: e.kind(),
    })?)
    .lines()
    .map(|l| l.unwrap())
    .filter(|l| !l.starts_with('#'));


@@ 59,10 71,7 @@ async fn main() -> anyhow::Result<()> {

        for i in posts.into_iter() {
            let conn = pool.acquire().await?;
            db::insert_item(conn, &i).await.context(format!(
                "Unable to insert item from {:?} with GUID {:?}",
                i.url, i.guid
            ))?;
            db::insert_item(conn, &i).await?;
        }
    }


M src/models.rs => src/models.rs +2 -69
@@ 1,70 1,3 @@
use chrono::DateTime;
pub mod post;

#[derive(Debug)]
pub struct Post {
    pub guid: String,
    pub title: Option<String>,
    pub url: Option<String>,
    pub pub_date: Option<i64>,
    pub content: Option<String>,
    pub sent: bool,
}

impl TryFrom<rss::Item> for Post {
    type Error = anyhow::Error;

    fn try_from(item: rss::Item) -> anyhow::Result<Self> {
        let time = item.pub_date().map(|date| {
            DateTime::parse_from_rfc2822(date)
                .unwrap_or_else(|_| DateTime::default())
                .timestamp()
        });

        let guid = item
            .guid()
            .ok_or_else(|| anyhow!("No guid found"))?
            .value()
            .to_string();
        let title = item.title().map(String::from);
        let url = item.link().map(String::from);
        let pub_date = time;
        let content = item
            .content()
            .or_else(|| item.description())
            .map(String::from);

        Ok(Self {
            guid,
            title,
            url,
            pub_date,
            content,
            sent: false,
        })
    }
}

impl TryFrom<atom_syndication::Entry> for Post {
    type Error = anyhow::Error;

    fn try_from(value: atom_syndication::Entry) -> Result<Self, Self::Error> {
        let guid = value.id.clone();
        let title = Some(value.title.value);
        let url = Some(value.id);
        let pub_date = value.published.map(|p| p.timestamp());
        let content = if let Some(c) = value.content {
            c.value
        } else {
            None
        };

        Ok(Self {
            guid,
            title,
            url,
            pub_date,
            content,
            sent: false,
        })
    }
}
pub use post::Post;

A src/models/error.rs => src/models/error.rs +7 -0
@@ 0,0 1,7 @@
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConversionError {
    #[error("The GUID field is missing")]
    MissingGUID,
}

A src/models/post.rs => src/models/post.rs +70 -0
@@ 0,0 1,70 @@
use chrono::DateTime;

use crate::error::ConversionError;

#[derive(Debug)]
pub struct Post {
    pub guid: String,
    pub title: Option<String>,
    pub url: Option<String>,
    pub pub_date: Option<i64>,
    pub content: Option<String>,
    pub sent: bool,
}

impl TryFrom<rss::Item> for Post {
    type Error = ConversionError;

    fn try_from(item: rss::Item) -> Result<Self, Self::Error> {
        let time = item.pub_date().map(|date| {
            DateTime::parse_from_rfc2822(date)
                .unwrap_or_else(|_| DateTime::default())
                .timestamp()
        });

        let guid = item
            .guid()
            .ok_or(ConversionError::MissingGUID)?
            .value()
            .to_string();
        let title = item.title().map(String::from);
        let url = item.link().map(String::from);
        let pub_date = time;
        let content = item
            .content()
            .or_else(|| item.description())
            .map(String::from);

        Ok(Self {
            guid,
            title,
            url,
            pub_date,
            content,
            sent: false,
        })
    }
}

impl From<atom_syndication::Entry> for Post {
    fn from(value: atom_syndication::Entry) -> Self {
        let guid = value.id.clone();
        let title = Some(value.title.value);
        let url = Some(value.id);
        let pub_date = value.published.map(|p| p.timestamp());
        let content = if let Some(c) = value.content {
            c.value
        } else {
            None
        };

        Self {
            guid,
            title,
            url,
            pub_date,
            content,
            sent: false,
        }
    }
}