Skip to content

Commit 5ca9827

Browse files
authored
Add email notification API (#617)
1 parent c77b395 commit 5ca9827

File tree

10 files changed

+235
-1
lines changed

10 files changed

+235
-1
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 0.4.5
2+
3+
* Add handling for `RemoveSignatory` from company, which flags the company as not active
4+
* Add email notifications API
5+
* Added `app_url` property to config - defaults to `https://bitcredit-dev.minibill.tech` (config break)
6+
17
# 0.4.4
28

39
* Add `num_confirmations_for_payment` config flag and a `payment_config` part of the api config, to configure the amount of confirmations needed until an on-chain payment is considered `paid`
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
use async_trait::async_trait;
2+
use bcr_ebill_core::{NodeId, ServiceTraitBounds, bill::BillId, notification::BillEventType};
3+
use borsh_derive::BorshSerialize;
4+
use nostr::hashes::Hash;
5+
use nostr::util::SECP256K1;
6+
use nostr::{hashes::sha256, nips::nip19::ToBech32};
7+
use secp256k1::{Keypair, Message};
8+
use serde::{Deserialize, Serialize};
9+
use thiserror::Error;
10+
11+
/// Generic result type
12+
pub type Result<T> = std::result::Result<T, super::Error>;
13+
14+
/// Generic error type
15+
#[derive(Debug, Error)]
16+
pub enum Error {
17+
/// all errors originating from interacting with the web api
18+
#[error("External Email Web API error: {0}")]
19+
Api(#[from] reqwest::Error),
20+
/// all errors originating from invalid urls
21+
#[error("External Email Invalid Relay Url Error")]
22+
InvalidRelayUrl,
23+
/// all hex errors
24+
#[error("External Email Hex Error: {0}")]
25+
Hex(#[from] hex::FromHexError),
26+
/// all signature errors
27+
#[error("External Email Signature Error: {0}")]
28+
Signature(#[from] secp256k1::Error),
29+
/// all nostr key errors
30+
#[error("External Email Nostr Key Error")]
31+
NostrKey,
32+
/// all borsh errors
33+
#[error("External Email Borsh Error")]
34+
Borsh(#[from] borsh::io::Error),
35+
}
36+
37+
#[cfg(test)]
38+
use mockall::automock;
39+
40+
use crate::{external::file_storage::to_url, get_config};
41+
42+
#[cfg_attr(test, automock)]
43+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
44+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
45+
pub trait EmailClientApi: ServiceTraitBounds {
46+
/// Start register flow, receiving a challenge string
47+
async fn start(&self, relay_url: &str, npub: &str) -> Result<StartEmailRegisterResponse>;
48+
/// Register for email notifications
49+
async fn register(
50+
&self,
51+
relay_url: &str,
52+
email: &str,
53+
private_key: &nostr::SecretKey,
54+
challenge: &str,
55+
) -> Result<RegisterEmailNotificationResponse>;
56+
/// Send a bill notification email
57+
async fn send_bill_notification(
58+
&self,
59+
relay_url: &str,
60+
kind: BillEventType,
61+
id: &BillId,
62+
receiver: &NodeId,
63+
private_key: &nostr::SecretKey,
64+
) -> Result<()>;
65+
}
66+
67+
#[derive(Debug, Clone, Default)]
68+
pub struct EmailClient {
69+
cl: reqwest::Client,
70+
}
71+
72+
impl ServiceTraitBounds for EmailClient {}
73+
74+
#[cfg(test)]
75+
impl ServiceTraitBounds for MockEmailClientApi {}
76+
77+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
78+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
79+
impl EmailClientApi for EmailClient {
80+
async fn start(&self, relay_url: &str, npub: &str) -> Result<StartEmailRegisterResponse> {
81+
let req = StartEmailRegisterRequest {
82+
npub: npub.to_owned(),
83+
};
84+
85+
let resp: StartEmailRegisterResponse = self
86+
.cl
87+
.post(to_url(relay_url, "notifications/v1/start")?)
88+
.json(&req)
89+
.send()
90+
.await?
91+
.json()
92+
.await?;
93+
94+
Ok(resp)
95+
}
96+
97+
async fn register(
98+
&self,
99+
relay_url: &str,
100+
email: &str,
101+
private_key: &nostr::SecretKey,
102+
challenge: &str,
103+
) -> Result<RegisterEmailNotificationResponse> {
104+
let key_pair = Keypair::from_secret_key(SECP256K1, private_key);
105+
let msg = Message::from_digest_slice(&hex::decode(challenge).map_err(Error::Hex)?)
106+
.map_err(Error::Signature)?;
107+
let signed_challenge = SECP256K1.sign_schnorr(&msg, &key_pair).to_string();
108+
109+
let npub = nostr::Keys::new(private_key.clone())
110+
.public_key()
111+
.to_bech32()
112+
.map_err(|_| Error::NostrKey)?;
113+
114+
let req = RegisterEmailNotificationRequest {
115+
email: email.to_owned(),
116+
ebill_url: get_config().app_url.to_owned(),
117+
npub,
118+
signed_challenge,
119+
};
120+
121+
let resp: RegisterEmailNotificationResponse = self
122+
.cl
123+
.post(to_url(relay_url, "notifications/v1/register")?)
124+
.json(&req)
125+
.send()
126+
.await?
127+
.json()
128+
.await?;
129+
130+
Ok(resp)
131+
}
132+
133+
async fn send_bill_notification(
134+
&self,
135+
relay_url: &str,
136+
kind: BillEventType,
137+
id: &BillId,
138+
receiver: &NodeId,
139+
private_key: &nostr::SecretKey,
140+
) -> Result<()> {
141+
let sender_npub = nostr::Keys::new(private_key.clone())
142+
.public_key()
143+
.to_bech32()
144+
.map_err(|_| Error::NostrKey)?;
145+
146+
let payload = SendEmailNotificationPayload {
147+
kind: kind.to_string(),
148+
id: id.to_string(),
149+
sender: sender_npub,
150+
receiver: receiver.npub().to_bech32().map_err(|_| Error::NostrKey)?,
151+
};
152+
153+
let key_pair = Keypair::from_secret_key(SECP256K1, private_key);
154+
let serialized = borsh::to_vec(&payload).map_err(Error::Borsh)?;
155+
let hash: sha256::Hash = sha256::Hash::hash(&serialized);
156+
let msg = Message::from_digest(*hash.as_ref());
157+
158+
let signature = SECP256K1.sign_schnorr(&msg, &key_pair).to_string();
159+
160+
let req = SendEmailNotificationRequest { payload, signature };
161+
162+
self.cl
163+
.post(to_url(relay_url, "notifications/v1/send")?)
164+
.json(&req)
165+
.send()
166+
.await?
167+
.error_for_status()?;
168+
169+
Ok(())
170+
}
171+
}
172+
173+
#[derive(Debug, Serialize)]
174+
pub struct StartEmailRegisterRequest {
175+
pub npub: String,
176+
}
177+
178+
#[derive(Debug, Deserialize)]
179+
pub struct StartEmailRegisterResponse {
180+
pub challenge: String,
181+
pub ttl_seconds: u32,
182+
}
183+
184+
#[derive(Debug, Serialize)]
185+
pub struct RegisterEmailNotificationRequest {
186+
pub email: String,
187+
pub ebill_url: url::Url,
188+
pub npub: String,
189+
pub signed_challenge: String,
190+
}
191+
192+
#[derive(Debug, Deserialize)]
193+
pub struct RegisterEmailNotificationResponse {
194+
pub preferences_token: String,
195+
}
196+
197+
#[derive(Debug, Serialize)]
198+
pub struct SendEmailNotificationRequest {
199+
pub payload: SendEmailNotificationPayload,
200+
pub signature: String,
201+
}
202+
203+
#[derive(Debug, Serialize, BorshSerialize)]
204+
pub struct SendEmailNotificationPayload {
205+
pub kind: String,
206+
pub id: String,
207+
pub sender: String,
208+
pub receiver: String,
209+
}

crates/bcr-ebill-api/src/external/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod bitcoin;
2+
pub mod email;
23
pub mod file_storage;
34
pub mod mint;
45
pub mod time;
@@ -23,4 +24,8 @@ pub enum Error {
2324
/// all errors originating from the external file storage API
2425
#[error("External File Storage API error: {0}")]
2526
ExternalFileStorageApi(#[from] file_storage::Error),
27+
28+
/// all errors originating from the external email API
29+
#[error("External EmailApi error: {0}")]
30+
ExternalEmailApi(#[from] email::Error),
2631
}

crates/bcr-ebill-api/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub use persistence::notification::NotificationFilter;
2323

2424
#[derive(Debug, Clone)]
2525
pub struct Config {
26+
pub app_url: url::Url,
2627
pub bitcoin_network: String,
2728
pub esplora_base_url: String,
2829
pub db_config: SurrealDbConfig,

crates/bcr-ebill-api/src/tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ pub mod tests {
415415
Some(_) => (),
416416
None => {
417417
let _ = crate::init(crate::Config {
418+
app_url: url::Url::parse("https://bitcredit-dev.minibill.tech").unwrap(),
418419
bitcoin_network: "testnet".to_string(),
419420
esplora_base_url: "https://esplora.minibill.tech".to_string(),
420421
db_config: SurrealDbConfig {

crates/bcr-ebill-core/src/notification.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::{
55
};
66
use serde::{Deserialize, Serialize};
77
use serde_json::Value;
8-
use std::fmt::Display;
8+
use std::fmt::{self, Display};
99
use uuid::Uuid;
1010

1111
/// A notification as it will be delivered to the UI.
@@ -111,6 +111,12 @@ pub enum BillEventType {
111111
BillBlock,
112112
}
113113

114+
impl fmt::Display for BillEventType {
115+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116+
write!(f, "{:?}", self)
117+
}
118+
}
119+
114120
impl BillEventType {
115121
pub fn all() -> Vec<Self> {
116122
vec![

crates/bcr-ebill-transport/src/notification_service.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,7 @@ mod tests {
865865
use bcr_ebill_core::blockchain::{Blockchain, BlockchainType};
866866
use bcr_ebill_core::util::{BcrKeys, date::now};
867867
use mockall::predicate::eq;
868+
use reqwest::Url;
868869
use std::sync::Arc;
869870

870871
use crate::test_utils::{
@@ -2023,6 +2024,7 @@ mod tests {
20232024
};
20242025

20252026
init(Config {
2027+
app_url: Url::parse("https://bitcredit-dev.minibill.tech").unwrap(),
20262028
bitcoin_network: "testnet".to_string(),
20272029
esplora_base_url: "https://esplora.minibill.tech".to_string(),
20282030
db_config: SurrealDbConfig::default(),

crates/bcr-ebill-wasm/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ tsify = { version = "0.4.5", features = ["js"] }
3333
bcr-ebill-api.workspace = true
3434
bcr-ebill-transport.workspace = true
3535
base64.workspace = true
36+
url.workspace = true

crates/bcr-ebill-wasm/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ document.getElementById("restore_account").addEventListener("click", restoreFrom
5858

5959
let config = {
6060
log_level: "debug",
61+
app_url: "https://bitcredit-dev.minibill.tech",
6162
// bitcoin_network: "regtest", // local reg test
6263
// esplora_base_url: "http://localhost:8094", // local reg test via docker-compose
6364
bitcoin_network: "testnet",

crates/bcr-ebill-wasm/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mod job;
2828
#[tsify(from_wasm_abi)]
2929
pub struct Config {
3030
pub log_level: Option<String>,
31+
pub app_url: String,
3132
pub bitcoin_network: String,
3233
pub esplora_base_url: String,
3334
pub nostr_relays: Vec<String>,
@@ -77,6 +78,7 @@ pub async fn initialize_api(
7778
.expect("can initialize logging");
7879
let mint_node_id = NodeId::from_str(&config.default_mint_node_id)?;
7980
let api_config = ApiConfig {
81+
app_url: url::Url::parse(&config.app_url).expect("app url is not a valid URL"),
8082
bitcoin_network: config.bitcoin_network,
8183
esplora_base_url: config.esplora_base_url,
8284
db_config: SurrealDbConfig::default(),

0 commit comments

Comments
 (0)