diff --git a/rechnung/cli.py b/rechnung/cli.py index 76f98de..a546bb0 100644 --- a/rechnung/cli.py +++ b/rechnung/cli.py @@ -1,10 +1,9 @@ import click import os -import sys from .settings import get_settings_from_cwd, copy_assets, create_required_settings_file from .invoice import create_invoices, render_invoices, send_invoices -from .contract import create_contracts, render_contracts, send_contract, get_contracts +from .contract import render_contracts, send_contract, get_contracts cwd = os.getcwd() diff --git a/rechnung/contract.py b/rechnung/contract.py index 64b4094..a6be231 100644 --- a/rechnung/contract.py +++ b/rechnung/contract.py @@ -4,14 +4,7 @@ from collections import OrderedDict from pathlib import Path -from .helpers import ( - generate_pdf, - get_pdf, - get_template, - generate_email_with_pdf_attachment, - generate_email_with_pdf_attachments, - send_email, -) +from .helpers import generate_pdf, get_template, generate_email, send_email def get_contracts(settings, year=None, month=None, inactive=False): @@ -80,41 +73,30 @@ def send_contract(settings, cid): contract_pdf_filename = f"{settings.company} {contract_yaml_filename.stem}.pdf" contract_mail_text = mail_template.render() - contract_pdf = get_pdf(contract_pdf_path) - pdf_documents = [contract_pdf] - pdf_filenames = [contract_pdf_filename] + attachments = [(contract_pdf_path, contract_pdf_filename)] for item in contract_data["items"]: item_pdf_file = f"{item['description']}.pdf" - if not item_pdf_file in pdf_filenames: - item_pdf_path = Path(settings.assets_dir / item_pdf_file) - if item_pdf_path.is_file(): - item_pdf = get_pdf(item_pdf_path) - pdf_documents.append(item_pdf) - pdf_filenames.append(item_pdf_file) - else: - print(f"Item file {item_pdf_file} not found") + item_pdf_path = Path(settings.assets_dir / item_pdf_file) + if item_pdf_path.is_file(): + attachments.append((item_pdf_path, item_pdf_file)) + else: + print(f"Item file {item_pdf_file} not found") if settings.policy_attachment_asset_file: policy_pdf_file = settings.policy_attachment_asset_file policy_pdf_path = settings.assets_dir / policy_pdf_file if policy_pdf_path.is_file(): - policy_pdf = get_pdf(policy_pdf_path) - pdf_documents.append(policy_pdf) - pdf_filenames.append(policy_pdf_file) + attachments.append((policy_pdf_path, policy_pdf_file)) else: - print( - f"Policy file {settings.policy_attachment_asset_file.name} not found" - ) + print(f"Missing {settings.policy_attachment_asset_file.name}") - contract_email = generate_email_with_pdf_attachments( + contract_email = generate_email( contract_data["email"], - settings.sender, settings.contract_mail_subject, contract_mail_text, - pdf_documents, - pdf_filenames, + attachments, ) print("Sending contract {}".format(contract_data["cid"])) diff --git a/rechnung/helpers.py b/rechnung/helpers.py index 6d466e9..5ac307e 100644 --- a/rechnung/helpers.py +++ b/rechnung/helpers.py @@ -1,15 +1,13 @@ -import yaml import smtplib import ssl +import yaml +from email.header import Header +from email.message import EmailMessage +import mimetypes from jinja2 import Template from weasyprint import HTML, CSS from weasyprint.fonts import FontConfiguration -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from email.mime.base import MIMEBase -from email import encoders -from email.utils import formatdate def get_template(template_filename): @@ -23,6 +21,7 @@ def get_template(template_filename): Returns: Template: jinja2 Template instance. """ + with open(template_filename) as template_file: return Template(template_file.read()) @@ -34,86 +33,57 @@ def send_email(msg, server, username, password, insecure=True): Args: msg (email.MIMEMultipart): The email to be sent. """ - conn = smtplib.SMTP(server, 587) - context = ssl.create_default_context() if not insecure else None - conn.starttls(context=context) - conn.login(username, password) - conn.send_message(msg) - conn.quit() - - -def generate_email_with_pdf_attachments( - mail_to, mail_from, mail_subject, mail_text, pdf_documents, attachment_filenames -): - if not len(pdf_documents) == len(attachment_filenames): - raise ValueError( - "pdf_documents and attachment_filenames must be of same length." - ) - - msg = MIMEMultipart() - msg["Subject"] = mail_subject - msg["From"] = mail_from - msg["To"] = mail_to - msg["Date"] = formatdate(localtime=True) - msg.attach(MIMEText(mail_text, "plain")) - - for document, filename in zip(pdf_documents, attachment_filenames): - payload = MIMEBase("application", "pdf") - payload.set_payload(document) - payload.add_header("Content-Disposition", "attachment", filename=filename) - - encoders.encode_base64(payload) - - msg.attach(payload) - - return msg - - -def generate_email_with_pdf_attachment( - mail_to, mail_from, mail_subject, mail_text, pdf_document, attachment_filename + try: + conn = smtplib.SMTP(server, 587) + context = ssl.create_default_context() if not insecure else None + conn.starttls(context=context) + conn.login(username, password) + conn.send_message(msg) + conn.quit() + return True + except Exception as e: + print(e) + quit(1) + + +def generate_email( + settings, mail_to: str, mail_subject: str, mail_text: str, files=None ): """ - Sends the invoice to the recipient. + Generate EmailMessage Args: - invoice_mail_text (str): Text for the email body - invoice_pdf (bytes): Invoice PDF - invoice_data (dict): Invoice Metadata object + settings + mail_to: receiver mail address + mail_subject: mail subject + mail_text: mail text + files (touple): list of file_path and file_name Returns: - email.MIMEMultipart: Invoice email message object + email.EmailMessage """ - - msg = MIMEMultipart() + msg = EmailMessage() + msg["To"] = Header(mail_to, "utf-8") msg["Subject"] = mail_subject - msg["From"] = mail_from - msg["To"] = mail_to - msg["Date"] = formatdate(localtime=True) - msg.attach(MIMEText(mail_text, "plain")) - - payload = MIMEBase("application", "pdf") - payload.set_payload(pdf_document) - payload.add_header( - "Content-Disposition", "attachment", filename=attachment_filename - ) - - encoders.encode_base64(payload) + msg["From"] = settings.sender - msg.attach(payload) - - return msg + msg.set_content(mail_text) + for file_path, file_name in files: + ctype, encoding = mimetypes.guess_type(file_path) + if ctype is None or encoding is not None: + ctype = "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + with open(file_path, "rb") as fp: + msg.add_attachment( + fp.read(), maintype=maintype, subtype=subtype, filename=file_name + ) -def get_pdf(filename): - """ - Reads a pdf file and returns its contents. + with open("outgoing.msg", "wb") as email_file: + email_file.write(bytes(msg)) - Args: - invoice_path (str) full path to the invoice file. - """ - with open(filename, "rb") as infile: - return infile.read() + return msg def generate_pdf(html_data, css_data, path): diff --git a/rechnung/invoice.py b/rechnung/invoice.py index 34dcfed..b37a460 100644 --- a/rechnung/invoice.py +++ b/rechnung/invoice.py @@ -1,17 +1,9 @@ import datetime import locale -from pathlib import Path import yaml -from .settings import get_settings_from_cwd from .contract import get_contracts -from .helpers import ( - generate_pdf, - get_pdf, - get_template, - generate_email_with_pdf_attachment, - send_email, -) +from .helpers import generate_pdf, get_template, generate_email, send_email def fill_invoice_items(settings, items): @@ -77,15 +69,14 @@ def iterate_invoices(settings): def render_invoices(settings): template = get_template(settings.invoice_template_file) - logo_path = settings.assets_dir / settings.logo_file for contract_invoice_dir, filename in iterate_invoices(settings): invoice_pdf_filename = filename.with_suffix(".pdf") if not invoice_pdf_filename.is_file(): with open(filename) as yaml_file: invoice_data = yaml.safe_load(yaml_file.read()) - invoice_data["logo_path"] = logo_path - invoice_data["company"] = settings.company + invoice_data.update(settings._asdict()) + print(invoice_data) print(f"Rendering invoice pdf for {invoice_data['id']}") @@ -133,43 +124,47 @@ def create_yaml_invoices(settings, contracts, year, month): save_invoice_yaml(settings, invoice_data) -def send_invoices(settings, year, month): +def send_invoices(settings, year, month, force_resend=False): mail_template = get_template(settings.invoice_mail_template_file) for d in settings.invoices_dir.iterdir(): customer_invoice_dir = settings.invoices_dir / d if customer_invoice_dir.iterdir(): for filename in customer_invoice_dir.glob("*.yaml"): - file_suffix = ".".join(filename.split(".")[-3:-1]) - - if file_suffix != f"{year}.{month:02}": + if not filename.name.endswith(f"{year}.{month:02}.yaml"): continue with open(customer_invoice_dir / filename) as yaml_file: invoice_data = yaml.safe_load(yaml_file) + # don't send invoices multiple times + if invoice_data.get("sent") and not force_resend: + print(f"Skip previously sent invoice {invoice_data['id']}") + continue + + invoice_pdf_filename = ( + f"{settings.company} {filename.with_suffix('.pdf').name}" + ) invoice_pdf_path = customer_invoice_dir / filename.with_suffix(".pdf") - invoice_pdf_filename = f"{settings.company} {filename[:-5]}.pdf" invoice_mail_text = mail_template.render(invoice=invoice_data) - invoice_pdf = get_pdf(invoice_pdf_path) - - invoice_receiver = invoice_data["email"] - invoice_email = generate_email_with_pdf_attachment( - invoice_receiver, - settings.sender, - settings.invoice_mail_subject, + invoice_email = generate_email( + settings, + invoice_data["email"], + f"{settings.invoice_mail_subject} {invoice_data['id']}", invoice_mail_text, - invoice_pdf, - invoice_pdf_filename, + [(invoice_pdf_path, invoice_pdf_filename)], ) print(f"Sending invoice {invoice_data['id']}") - send_email( + if send_email( invoice_email, settings.server, settings.username, settings.password, settings.insecure, - ) + ): + with open(customer_invoice_dir / filename, "w") as yaml_file: + invoice_data["sent"] = True + yaml_file.write(yaml.dump(invoice_data)) diff --git a/rechnung/settings.py b/rechnung/settings.py index 3147271..56d5062 100644 --- a/rechnung/settings.py +++ b/rechnung/settings.py @@ -19,9 +19,10 @@ # in the settings.yaml required_settings = [ "company", + "company_address", + "company_bank", "contract_mail_subject", "insecure", - "invoice_mail_subject", "locale", "password", "sender", @@ -30,6 +31,7 @@ "vat", ] optional_settings = { + "invoice_mail_subject": "Rechnung", "assets_dir": "assets", "contract_css_asset_file": "contract.css", "contract_mail_template_file": "contract_mail_template.j2", @@ -40,7 +42,7 @@ "invoice_mail_template_file": "invoice_mail_template.j2", "invoice_template_file": "invoice_template.j2.html", "invoices_dir": "invoices", - "logo_file": "logo.svg", + "logo_asset_file": "logo.svg", "policy_attachment_asset_file": "policy.pdf", } possible_settings = set(required_settings + list(optional_settings.keys())) @@ -90,7 +92,7 @@ def get_settings_from_file( Opens a settings.yaml and returns its contents as a namedtuple "Settings". It checks if all required settings are found in the settings file, as well - if there are any unknown settings given. + if there are any unknown settings given. Finally the base_path is prepended to all settings ending with "_file" or "_dir". """ @@ -135,8 +137,8 @@ def get_settings_from_file( def copy_assets(target_dir, orig_dir=od): """ - Copy the original assets, which are shipped with the tool, - from the original directory (where the tool is installed) + Copy the original assets, which are shipped with the tool, + from the original directory (where the tool is installed) to the cwd (where the data is stored). """ for asset in orig_dir.glob("*"):