Repository: UAlbanyArchives/mailbagit (language: python)

Issue:
First pass at structlog configuration.
 ## Type of Contribution

- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New component
- [x] Refactoring (no functional changes)
- [ ] Documentation-only

## What does this implement/fix? Explain your changes.
This implements a structlog configuration that:
- is only applied if the app is run via main - if it's being imported for use as a library no log configuration is done
- supports passing a filename in the args
- supports configuration via log variables
...

## Link to issue?
#83 
...

- [ ] Issue closed
- [x] Remain open

## Pull Request Checklist

Please check if your PR fulfills the following requirements:
- [x] Make sure you are requesting to the develop branch. Don't PR to main!
- [ ] This contribution has sufficient documentation
- [ ] Tests for the changes have been added
- [ ] All tests pass

#### How has this been tested?
**Operating System:** …
**Python Version:** …

## Licensing
- [x] I agree that the Mailbag Project and the University at Albany, SUNY can release this code under the [MIT license](https://github.com/UAlbanyArchives/mailbagit/blob/main/LICENSE).



Discussion / hints:

Fix patch:
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f1ee62c
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "docs/mailbag-specification"]
+	path = docs/mailbag-specification
+	url = git@github.com:UAlbanyArchives/mailbag-specification.git
diff --git a/data/eml-2016-06-23_144430_6e449c77fe/1/Headers.pickle b/data/eml-2016-06-23_144430_6e449c77fe/1/Headers.pickle
index f8650fe..199f52f 100644
Binary files a/data/eml-2016-06-23_144430_6e449c77fe/1/Headers.pickle and b/data/eml-2016-06-23_144430_6e449c77fe/1/Headers.pickle differ
diff --git a/data/eml-2016-06-23_144430_6e449c77fe/1/Message.pickle b/data/eml-2016-06-23_144430_6e449c77fe/1/Message.pickle
index f8650fe..199f52f 100644
Binary files a/data/eml-2016-06-23_144430_6e449c77fe/1/Message.pickle and b/data/eml-2016-06-23_144430_6e449c77fe/1/Message.pickle differ
diff --git a/data/mbox-sample1/1/Headers.pickle b/data/mbox-sample1/1/Headers.pickle
index 88f57ab..4895a23 100644
Binary files a/data/mbox-sample1/1/Headers.pickle and b/data/mbox-sample1/1/Headers.pickle differ
diff --git a/data/mbox-sample1/1/Message.pickle b/data/mbox-sample1/1/Message.pickle
index d3ad047..0ed6809 100644
Binary files a/data/mbox-sample1/1/Message.pickle and b/data/mbox-sample1/1/Message.pickle differ
diff --git a/data/mbox-sample1/2/Headers.pickle b/data/mbox-sample1/2/Headers.pickle
index 3e9cecd..916f64a 100644
Binary files a/data/mbox-sample1/2/Headers.pickle and b/data/mbox-sample1/2/Headers.pickle differ
diff --git a/data/mbox-sample1/2/Message.pickle b/data/mbox-sample1/2/Message.pickle
index 3bd3d8d..9f0c3d8 100644
Binary files a/data/mbox-sample1/2/Message.pickle and b/data/mbox-sample1/2/Message.pickle differ
diff --git a/data/msg-Digitization Archiving Solutions/1/Attachments/1/Content_ID.txt b/data/msg-Digitization Archiving Solutions/1/Attachments/1/Content_ID.txt
index 9b83e84..06fa10f 100644
--- a/data/msg-Digitization Archiving Solutions/1/Attachments/1/Content_ID.txt
+++ b/data/msg-Digitization Archiving Solutions/1/Attachments/1/Content_ID.txt
@@ -1,1 +1,1 @@
-b77f8b0f48324f4ea767df91f1a85ff8
\ No newline at end of file
+image001.png@01D81905.3EA3C240
\ No newline at end of file
diff --git a/data/msg-Digitization Archiving Solutions/1/Attachments/1/WrittenName.txt b/data/msg-Digitization Archiving Solutions/1/Attachments/1/WrittenName.txt
new file mode 100644
index 0000000..bfff8e0
--- /dev/null
+++ b/data/msg-Digitization Archiving Solutions/1/Attachments/1/WrittenName.txt
@@ -0,0 +1,1 @@
+image001.png
\ No newline at end of file
diff --git a/data/msg-Digitization Archiving Solutions/1/Attachments/2/Content_ID.txt b/data/msg-Digitization Archiving Solutions/1/Attachments/2/Content_ID.txt
index 7cf0165..690caf2 100644
--- a/data/msg-Digitization Archiving Solutions/1/Attachments/2/Content_ID.txt
+++ b/data/msg-Digitization Archiving Solutions/1/Attachments/2/Content_ID.txt
@@ -1,1 +1,1 @@
-1eed379e88f1438f9517c20f558bbff8
\ No newline at end of file
+604E1A43B0B2504E99BDF75DFDC8DD69@namprd04.prod.outlook.com
\ No newline at end of file
diff --git a/data/msg-Digitization Archiving Solutions/1/Attachments/2/WrittenName.txt b/data/msg-Digitization Archiving Solutions/1/Attachments/2/WrittenName.txt
new file mode 100644
index 0000000..7f6956b
--- /dev/null
+++ b/data/msg-Digitization Archiving Solutions/1/Attachments/2/WrittenName.txt
@@ -0,0 +1,1 @@
+CrowleyAdvantage-Hardware.pdf
\ No newline at end of file
diff --git a/data/msg-Digitization Archiving Solutions/1/Attachments/3/Content_ID.txt b/data/msg-Digitization Archiving Solutions/1/Attachments/3/Content_ID.txt
index 161a59d..c121c83 100644
--- a/data/msg-Digitization Archiving Solutions/1/Attachments/3/Content_ID.txt
+++ b/data/msg-Digitization Archiving Solutions/1/Attachments/3/Content_ID.txt
@@ -1,1 +1,1 @@
-64ecedb978594310ac86265701e24b29
\ No newline at end of file
+538183EAA0AF4B46A9F5AFC2A54B557D@namprd04.prod.outlook.com
\ No newline at end of file
diff --git a/data/msg-Digitization Archiving Solutions/1/Attachments/3/WrittenName.txt b/data/msg-Digitization Archiving Solutions/1/Attachments/3/WrittenName.txt
new file mode 100644
index 0000000..ecb55dc
--- /dev/null
+++ b/data/msg-Digitization Archiving Solutions/1/Attachments/3/WrittenName.txt
@@ -0,0 +1,1 @@
+Crowley Modern Library Award Winning Scanners and Services_2022.pdf
\ No newline at end of file
diff --git a/data/msg-Digitization Archiving Solutions/1/Headers.pickle b/data/msg-Digitization Archiving Solutions/1/Headers.pickle
index a35d924..2135cc2 100644
Binary files a/data/msg-Digitization Archiving Solutions/1/Headers.pickle and b/data/msg-Digitization Archiving Solutions/1/Headers.pickle differ
diff --git a/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/1/Headers.pickle b/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/1/Headers.pickle
index 5eb858a..4738360 100644
Binary files a/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/1/Headers.pickle and b/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/1/Headers.pickle differ
diff --git a/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/2/Headers.pickle b/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/2/Headers.pickle
index 8f56a79..6358cdc 100644
Binary files a/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/2/Headers.pickle and b/data/pst-outlook2019_MSO_16.0.10377.20023_64-bit/2/Headers.pickle differ
diff --git a/docs/Gemfile b/docs/Gemfile
index dbf8caa..dc293cc 100644
--- a/docs/Gemfile
+++ b/docs/Gemfile
@@ -9,11 +9,12 @@ source "https://rubygems.org"
 # This will help ensure the proper Jekyll version is running.
 # Happy Jekylling!
 #gem "jekyll", "~> 3.8.5"
-gem "jekyll", "~> 4.1.0"
+gem "jekyll", "~> 4.2.2"
+gem "rake"
 
 # This is the default theme for new Jekyll sites. You may change this to anything you like.
 #gem "minima", "~> 2.0"
-gem "just-the-docs"
+#gem "just-the-docs"
 
 # If you want to use GitHub Pages, remove the "gem "jekyll"" above and
 # uncomment the line below. To upgrade, run `bundle update github-pages`.
@@ -22,6 +23,12 @@ gem "just-the-docs"
 # If you have any plugins, put them here!
 group :jekyll_plugins do
   gem "jekyll-feed", "~> 0.6"
+  gem "jekyll-remote-theme"
+  gem "jekyll-seo-tag"
+  gem "jekyll-sitemap"
+  gem "jekyll-watch"
+  gem "jekyll-sass-converter"
+  #gem "github-pages", "226"
 end
 
 # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 39ff3a4..6da237a 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -14,33 +14,36 @@ GEM
     http_parser.rb (0.8.0)
     i18n (1.10.0)
       concurrent-ruby (~> 1.0)
-    jekyll (4.1.1)
+    jekyll (4.2.2)
       addressable (~> 2.4)
       colorator (~> 1.0)
       em-websocket (~> 0.5)
       i18n (~> 1.0)
       jekyll-sass-converter (~> 2.0)
       jekyll-watch (~> 2.0)
-      kramdown (~> 2.1)
+      kramdown (~> 2.3)
       kramdown-parser-gfm (~> 1.0)
       liquid (~> 4.0)
       mercenary (~> 0.4.0)
       pathutil (~> 0.9)
       rouge (~> 3.0)
       safe_yaml (~> 1.0)
-      terminal-table (~> 1.8)
+      terminal-table (~> 2.0)
     jekyll-feed (0.16.0)
       jekyll (>= 3.7, < 5.0)
+    jekyll-remote-theme (0.4.3)
+      addressable (~> 2.0)
+      jekyll (>= 3.5, < 5.0)
+      jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0)
+      rubyzip (>= 1.3.0, < 3.0)
     jekyll-sass-converter (2.2.0)
       sassc (> 2.0.1, < 3.0)
     jekyll-seo-tag (2.8.0)
       jekyll (>= 3.8, < 5.0)
+    jekyll-sitemap (1.4.0)
+      jekyll (>= 3.7, < 5.0)
     jekyll-watch (2.2.1)
       listen (~> 3.0)
-    just-the-docs (0.3.3)
-      jekyll (>= 3.8.5)
-      jekyll-seo-tag (~> 2.0)
-      rake (>= 12.3.1, < 13.1.0)
     kramdown (2.4.0)
       rexml
     kramdown-parser-gfm (1.1.0)
@@ -58,11 +61,12 @@ GEM
     rb-inotify (0.10.1)
       ffi (~> 1.0)
     rexml (3.2.5)
-    rouge (3.28.0)
+    rouge (3.29.0)
+    rubyzip (2.3.2)
     safe_yaml (1.0.5)
     sassc (2.4.0)
       ffi (~> 1.9)
-    terminal-table (1.8.0)
+    terminal-table (2.0.0)
       unicode-display_width (~> 1.1, >= 1.1.1)
     unicode-display_width (1.8.0)
 
@@ -71,10 +75,15 @@ PLATFORMS
 
 DEPENDENCIES
   addressable (>= 2.8.0)
-  jekyll (~> 4.1.0)
+  jekyll (~> 4.2.2)
   jekyll-feed (~> 0.6)
-  just-the-docs
+  jekyll-remote-theme
+  jekyll-sass-converter
+  jekyll-seo-tag
+  jekyll-sitemap
+  jekyll-watch
   kramdown (>= 2.3.1)
+  rake
   tzinfo-data
 
 BUNDLED WITH
diff --git a/docs/_config.yml b/docs/_config.yml
index c5ac364..2c8d88c 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -24,8 +24,9 @@ github_username:  jekyll
 
 # Build settings
 markdown: kramdown
-theme: just-the-docs
+#theme: just-the-docs
 #remote_theme: pmarsceill/just-the-docs
+remote_theme: just-the-docs/just-the-docs
 plugins:
   - jekyll-feed
 
diff --git a/docs/_includes/vendor/anchor_headings.html b/docs/_includes/vendor/anchor_headings.html
new file mode 100644
index 0000000..7912f6b
--- /dev/null
+++ b/docs/_includes/vendor/anchor_headings.html
@@ -0,0 +1,144 @@
+{% capture headingsWorkspace %}
+  {% comment %}
+    Copyright (c) 2018 Vladimir "allejo" Jimenez
+
+    Permission is hereby granted, free of charge, to any person
+    obtaining a copy of this software and associated documentation
+    files (the "Software"), to deal in the Software without
+    restriction, including without limitation the rights to use,
+    copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the
+    Software is furnished to do so, subject to the following
+    conditions:
+
+    The above copyright notice and this permission notice shall be
+    included in all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+    EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+    OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+    NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+    HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+    WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+    OTHER DEALINGS IN THE SOFTWARE.
+  {% endcomment %}
+  {% comment %}
+    Version 1.0.7
+      https://github.com/allejo/jekyll-anchor-headings
+
+    "Be the pull request you wish to see in the world." ~Ben Balter
+
+    Usage:
+      {% include anchor_headings.html html=content anchorBody="#" %}
+
+    Parameters:
+      * html          (string) - the HTML of compiled markdown generated by kramdown in Jekyll
+
+    Optional Parameters:
+      * beforeHeading (bool)   : false  - Set to true if the anchor should be placed _before_ the heading's content
+      * anchorAttrs   (string) :  ''    - Any custom HTML attributes that will be added to the `<a>` tag; you may NOT use `href`, `class` or `title`;
+                                          the `%heading%` and `%html_id%` placeholders are available
+      * anchorBody    (string) :  ''    - The content that will be placed inside the anchor; the `%heading%` placeholder is available
+      * anchorClass   (string) :  ''    - The class(es) that will be used for each anchor. Separate multiple classes with a space
+      * anchorTitle   (string) :  ''    - The `title` attribute that will be used for anchors
+      * h_min         (int)    :  1     - The minimum header level to build an anchor for; any header lower than this value will be ignored
+      * h_max         (int)    :  6     - The maximum header level to build an anchor for; any header greater than this value will be ignored
+      * bodyPrefix    (string) :  ''    - Anything that should be inserted inside of the heading tag _before_ its anchor and content
+      * bodySuffix    (string) :  ''    - Anything that should be inserted inside of the heading tag _after_ its anchor and content
+
+    Output:
+      The original HTML with the addition of anchors inside of all of the h1-h6 headings.
+  {% endcomment %}
+
+  {% assign minHeader = include.h_min | default: 1 %}
+  {% assign maxHeader = include.h_max | default: 6 %}
+  {% assign beforeHeading = include.beforeHeading %}
+  {% assign nodes = include.html | split: '<h' %}
+
+  {% capture edited_headings %}{% endcapture %}
+
+  {% for _node in nodes %}
+    {% capture node %}{{ _node | strip }}{% endcapture %}
+
+    {% if node == "" %}
+      {% continue %}
+    {% endif %}
+
+    {% assign nextChar = node | replace: '"', '' | strip | slice: 0, 1 %}
+    {% assign headerLevel = nextChar | times: 1 %}
+
+    <!-- If the level is cast to 0, it means it's not a h1-h6 tag, so let's see if we need to fix it -->
+    {% if headerLevel == 0 %}
+      <!-- Split up the node based on closing angle brackets and get the first one. -->
+      {% assign firstChunk = node | split: '>' | first %}
+
+      <!-- If the first chunk does NOT contain a '<', that means we've broken another HTML tag that starts with 'h' -->
+      {% unless firstChunk contains '<' %}
+        {% capture node %}<h{{ node }}{% endcapture %}
+      {% endunless %}
+
+      {% capture edited_headings %}{{ edited_headings }}{{ node }}{% endcapture %}
+      {% continue %}
+    {% endif %}
+
+    {% capture _closingTag %}</h{{ headerLevel }}>{% endcapture %}
+    {% assign _workspace = node | split: _closingTag %}
+    {% assign _idWorkspace = _workspace[0] | split: 'id="' %}
+    {% assign _idWorkspace = _idWorkspace[1] | split: '"' %}
+    {% assign html_id = _idWorkspace[0] %}
+
+    {% capture _hAttrToStrip %}{{ _workspace[0] | split: '>' | first }}>{% endcapture %}
+    {% assign header = _workspace[0] | replace: _hAttrToStrip, '' %}
+
+    <!-- Build the anchor to inject for our heading -->
+    {% capture anchor %}{% endcapture %}
+
+    {% if html_id and headerLevel >= minHeader and headerLevel <= maxHeader %}
+      {% capture anchor %}href="#{{ html_id  | downcase }}"{% endcapture %}
+
+      {% if include.anchorClass %}
+        {% capture anchor %}{{ anchor }} class="{{ include.anchorClass }}"{% endcapture %}
+      {% endif %}
+
+      {% if include.anchorTitle %}
+        {% capture anchor %}{{ anchor }} title="{{ include.anchorTitle | replace: '%heading%', header }}"{% endcapture %}
+      {% endif %}
+
+      {% if include.anchorAttrs %}
+        {% capture anchor %}{{ anchor }} {{ include.anchorAttrs | replace: '%heading%', header | replace: '%html_id%', html_id }}{% endcapture %}
+      {% endif %}
+
+      {% capture anchor %}<a {{ anchor }}>{{ include.anchorBody | replace: '%heading%', header | default: '' }}</a>{% endcapture %}
+
+      <!-- In order to prevent adding extra space after a heading, we'll let the 'anchor' value contain it -->
+      {% if beforeHeading %}
+        {% capture anchor %}{{ anchor }} {% endcapture %}
+      {% else %}
+        {% capture anchor %} {{ anchor }}{% endcapture %}
+      {% endif %}
+    {% endif %}
+
+    {% capture new_heading %}
+      <h{{ _hAttrToStrip }}
+        {{ include.bodyPrefix }}
+        {% if beforeHeading %}
+          {{ anchor }}{{ header }}
+        {% else %}
+          {{ header }}{{ anchor }}
+        {% endif %}
+        {{ include.bodySuffix }}
+      </h{{ headerLevel }}>
+    {% endcapture %}
+
+    <!--
+    If we have content after the `</hX>` tag, then we'll want to append that here so we don't lost any content.
+    -->
+    {% assign chunkCount = _workspace | size %}
+    {% if chunkCount > 1 %}
+      {% capture new_heading %}{{ new_heading }}{{ _workspace | last }}{% endcapture %}
+    {% endif %}
+
+    {% capture edited_headings %}{{ edited_headings }}{{ new_heading }}{% endcapture %}
+  {% endfor %}
+{% endcapture %}{% assign headingsWorkspace = '' %}{{ edited_headings | strip }}
diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss
new file mode 100644
index 0000000..fc96248
--- /dev/null
+++ b/docs/_sass/custom/custom.scss
@@ -0,0 +1,4 @@
+.main-content .highlight pre.highlight code {
+	line-height: 1.5;
+	display: block;
+}
\ No newline at end of file
diff --git a/docs/specification.md b/docs/changelog/changelog.md
similarity index 91%
rename from docs/specification.md
rename to docs/changelog/changelog.md
index 078d115..bfff641 100644
--- a/docs/specification.md
+++ b/docs/changelog/changelog.md
@@ -1,11 +1,22 @@
 ---
 layout: page
-title: Mailbag Specification
-permalink: /spec/
-nav_order: 2
+title: Changelog
+permalink: /spec/changelog/
+parent: Mailbag Specification
+nav_order: 1
 ---
 
-# Mailbag Specification
+# Mailbag Specification Changelog
+
+## [Mailbag Specification 1.0.0](https://github.com/UAlbanyArchives/mailbag-specification/tree/v1.0.0)
+
+### Release Notes
+
+* Old `original_filenames.txt` for attachments renamed as `attachments.csv` and now required to better handle missing filenames and filename normalization.
+* CSV rules clarified and moved to Section 5.5 as they now impact both `mailbag.csv` and `attachments.csv`.
+* Added explicit support for "companion" metadata files in mailbags.
+* Added PREMIS XML examples provided by [@asciim0](https://github.com/asciim0).
+* Reworded sections based on comments on [0.3 release](https://docs.google.com/document/d/1BZHklc6MKktXJBPcvFvlxLRoX8lCidFemflppqpUQ7s/edit#heading=h.7l1mpm88wrx5). 
 
 ## [Mailbag Specification 0.3 (Release Candidate)](https://docs.google.com/document/d/1BZHklc6MKktXJBPcvFvlxLRoX8lCidFemflppqpUQ7s)
 
diff --git a/docs/contributors.md b/docs/contributors.md
index bd92a80..36d59bd 100755
--- a/docs/contributors.md
+++ b/docs/contributors.md
@@ -1,11 +1,11 @@
 ---
 layout: page
-title: Call for Contributors
+title: Call for Contributors (closed)
 permalink: /contributors/
 nav_order: 9
 ---
 
-# Call for Contributors
+# Call for Contributors (closed)
 
 The Mailbag project team is seeking community members interested in contributing to the `mailbagit` tool. This is open to those with Python experience looking to address [known issues](https://github.com/UAlbanyArchives/mailbag/issues) or review existing code, as well as non-coders interested in improving documentation.
 
diff --git a/docs/img/mailbagit-guided.png b/docs/img/mailbagit-guided.png
new file mode 100644
index 0000000..8ba4ca8
Binary files /dev/null and b/docs/img/mailbagit-guided.png differ
diff --git a/docs/index.md b/docs/index.md
index f5515e5..5600321 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -3,7 +3,7 @@
 # To modify the layout, see https://jekyllrb.com/docs/themes/#overriding-theme-defaults
 
 layout: home
-nav_order: 1
+nav_exclude: true
 ---
 
 # Mailbag
@@ -12,7 +12,8 @@ nav_order: 1
 A Stable Package for Email in Multiple Formats
 {: .fs-6 .fw-300 }
 
-[GitHub](https://github.com/UAlbanyArchives/mailbag){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 }
+[Mailbag Specification]({{ site.baseurl }}/spec){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 }
+[Mailbagit Github](https://github.com/UAlbanyArchives/mailbagit){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 }
 
 ---
 
diff --git a/docs/installing_mailbagit/docker.md b/docs/installing_mailbagit/docker.md
index 5315252..77fced1 100755
--- a/docs/installing_mailbagit/docker.md
+++ b/docs/installing_mailbagit/docker.md
@@ -66,7 +66,7 @@ You can then click the "CLI" button for command line access to `mailbagit`.
 
 ![Screenshot showing how to get command line access to the container using Docker Desktop.]({{ site.baseurl }}/img/docker3.png)
 
-Remember to stop the container when your done!
+Remember to stop the container when you're done!
 
 ## Development Docker image
 
diff --git a/docs/mailbag-specification b/docs/mailbag-specification
new file mode 160000
index 0000000..bcf81da
--- /dev/null
+++ b/docs/mailbag-specification
@@ -0,0 +1,1 @@
+Subproject commit bcf81dac0b18383fc82f8a1e4c88bfe23a645509
diff --git a/docs/use.md b/docs/use.md
index c651ee9..ff21c37 100755
--- a/docs/use.md
+++ b/docs/use.md
@@ -11,9 +11,9 @@ has_children: true
 Mailbag contains multiple tools for packaging mailbags according to the [Mailbag Specification]({{ site.baseurl }}/spec/).
 
 * [mailbagit]({{ site.baseurl }}/mailbagit/), a command line tool for creating mailbags
-* [mailbagit-gui]({{ site.baseurl }}/mailbagit-gui/), a basic graphical user interface which performs the same functions as `mailbagit` 
-* a planned guided tool for accessible use by non-technical users
+* [mailbagit-gui]({{ site.baseurl }}/mailbagit-gui/), a basic graphical user interface which performs the same functions as `mailbagit`
+* [mailbagit-guided]({{ site.baseurl }}/mailbagit-guided/), a simplified CLI interface designed for accessible use by non-technical users 
 
 ⚠️ Creating PDF or WARC derivatives may cause [privacy or security concerns]({{ site.baseurl }}/concerns) in some contexts.
 
-You can [customize logging]({{ site.baseurl }}/logging/) and [create plugins]({{ site.baseurl }}/plugins/) for both `mailbagit` and `mailbagit-gui`.
+You can [customize logging]({{ site.baseurl }}/logging/) and [create plugins]({{ site.baseurl }}/plugins/) for all `mailbagit` options.
diff --git a/docs/using_mailbagit/concerns.md b/docs/using_mailbagit/concerns.md
index 4a82a56..5826ec6 100755
--- a/docs/using_mailbagit/concerns.md
+++ b/docs/using_mailbagit/concerns.md
@@ -3,7 +3,7 @@ layout: page
 title: Privacy and security concerns
 permalink: /concerns/
 parent: Using mailbagit
-nav_order: 6
+nav_order: 8
 ---
 
 # Privacy and security concerns
diff --git a/docs/using_mailbagit/errors.md b/docs/using_mailbagit/errors.md
new file mode 100755
index 0000000..fca1b06
--- /dev/null
+++ b/docs/using_mailbagit/errors.md
@@ -0,0 +1,108 @@
+---
+layout: page
+title: Error and warning reports
+permalink: /errors/
+parent: Using mailbagit
+nav_order: 4
+---
+
+# Error and warning reports
+
+When there are issues during packaging, `mailbagit` creates separate directories using the `-m` or `--mailbag-name` argument.
+
+* `fundraising_emails_errors`
+* `fundraising_emails_warnings`
+
+Within the reports directories `mailbagit` creates `errors.csv` or `warnings.csv` files listing all issues. Reports also include a `.txt` file for each message with an issue that contains a full stack trace when relevant. 
+
+## Reports when using `--dry-run`
+
+Mailbagit creates error and warning reports during a test run using the optional argument `-r` or `--dry-run`. We recommend using this argument first to show if anything unexpected will happen during packaging.
+
+Most, but not all errors and warnings will be included in the reports during a test run. Using `--dry-run` fully parses the provided email messages, so any issues found there will be included. However a test run does not write anything to the filesystem, so errors or warnings that may happen during that process will not be included using `--dry-run`.
+
+Examples of errors and warnings that are not included when using `--dry-run`:
+
+* Issues writing files or directories to disk
+* Issues when using `wkhtmltopdf` or `chrome --headless` to generate PDF derivatives
+* Issues when writing WARC derivatives, like 404 or 400 HTTP responses or SSL errors.
+
+## Example Error report
+
+    fundraising_emails/
+    |    |
+    |    +-- bagit.txt
+    |    |
+    |    +-- bag-info.txt
+    |    |
+    |    +-- mailbag.csv
+    |    |
+    |    ...
+    fundraising_emails_errors/
+    |    |
+    |    +-- 136.txt
+    |    |
+    |    +-- 2284.txt
+    |    |
+    |    +-- errors.csv
+    |     
+    fundraising_emails_warnings/
+         |
+         +-- 25.txt
+         |
+         +-- 514.txt
+         |
+         +-- 835.txt
+         |
+         +-- 1485.txt
+         |
+         +-- 3263.txt
+         |
+         +-- warnings.csv
+
+
+## Common Warnings
+
+### 404s for external resources
+
+* `wkhtmltopdf` raises warnings when image URLs and other externally-hosted resources are not found (404) when a PDF is created. These raise a warning and are included in warnings reports when the `pdf` derivative option is selected. These warnings are not included when the `--dry-run` option is used.
+
+```
+WARN: Error converting to 254.pdf: Loading pages (1/6)
+[>                                                           ] 0%
+[======>                                                     ] 10%
+[======>                                                     ] 10%
+[======>                                                     ] 10%
+[======>                                                     ] 10%
+[======>                                                     ] 10%
+[==========================>                                 ] 44%
+Error: Failed to load cid:1469322111, with network status code 301 and http status code 0 - Protocol "cid" is unknown
+[============================================================] 100%
+Counting pages (2/6)                                               
+[============================================================] Object 1 of 1
+Resolving links (4/6)                                                       
+[============================================================] Object 1 of 1
+Loading headers and footers (5/6)                                           
+Printing pages (6/6)
+[>                                                           ] Preparing
+[============================================================] Page 1 of 1
+Done                                                                      
+Exit with code 1 due to network error: ProtocolUnknownError
+.
+```
+
+* `chrome --headless` DOES NOT raise any issues when 404s are found, so when the `pdf-chrome` derivative option is selected this information will not be including in any error or warning reports.
+
+* WARC derivatives generate warnings when external resources do not return a HTTP 200 response.
+
+```
+WARN: When writing WARC derivative, HTTP 403 Forbidden for external resource https://www.example.com/.
+```
+
+### Missing message bodies
+
+* `No Body Present` is a warning that occurs when `mailbagit` finds a message that does not contain a HTML or plain text body.
+
+```
+No HTML or plain text body for 524, no HTML derivative created.
+```
diff --git a/docs/using_mailbagit/logging.md b/docs/using_mailbagit/logging.md
index 6886205..adf2175 100755
--- a/docs/using_mailbagit/logging.md
+++ b/docs/using_mailbagit/logging.md
@@ -3,7 +3,7 @@ layout: page
 title: Configuring Logging
 permalink: /logging/
 parent: Using mailbagit
-nav_order: 3
+nav_order: 5
 ---
 
 # Configuring Logging
diff --git a/docs/using_mailbagit/lossiness.md b/docs/using_mailbagit/lossiness.md
index bfcf9ab..853f531 100755
--- a/docs/using_mailbagit/lossiness.md
+++ b/docs/using_mailbagit/lossiness.md
@@ -3,7 +3,7 @@ layout: page
 title: Lossiness in Derivatives
 permalink: /lossiness/
 parent: Using mailbagit
-nav_order: 5
+nav_order: 7
 ---
 
 # Lossiness in Derivatives
diff --git a/docs/using_mailbagit/mailbagit-gui.md b/docs/using_mailbagit/mailbagit-gui.md
index 87a1dbd..752166c 100755
--- a/docs/using_mailbagit/mailbagit-gui.md
+++ b/docs/using_mailbagit/mailbagit-gui.md
@@ -12,13 +12,16 @@ Mailbagit includes a very basic graphical user interface (GUI)
 
 If `mailbagit` is [installed]({{ site.baseurl }}/python/) you should be able to run the GUI by entering the `mailbagit-gui` command. The mailbagit GUI is currently not supported while running in a docker container.
 
+[Download and install mailbagit]({{ site.baseurl }}/install).
+[Download a sample MBOX file]({{ site.baseurl }}/data/account.mbox) for training and testing.
+
 ## Accessibility
 
 The mailbagit GUI currently has severe accessibility limitations. Most notably, when using a screen reader with `mailbagit-gui` the tab key does not change the focus to the input fields. Once you click into the scrollable window, the tab key does work. The multi-option derivatives field is also not accessible using a keyboard and requires clicking with a mouse.
 
-This is due to [accessibility issues](https://github.com/chriskiehl/Gooey/issues/747) with underlying dependencies wxPython and wxWidgets, and unfortunately, we don't have he capacity to do much about it.
+This is due to [accessibility issues](https://github.com/chriskiehl/Gooey/issues/747) with underlying dependencies wxPython and wxWidgets, and unfortunately, we don't have the capacity to do much about it.
 
-Instead, we plan to implement a "guided" command line option, which we hope may provide accessible access to users without command line experience.
+Instead, we suggest using the ["guided" command line option]({{ site.baseurl }}/mailbagit-guided/), which should provide accessible access and may be easier for users without command line experience.
 
 ## Mailbagit GUI on Windows 
 
diff --git a/docs/using_mailbagit/mailbagit-guided.md b/docs/using_mailbagit/mailbagit-guided.md
new file mode 100644
index 0000000..aa59ff9
--- /dev/null
+++ b/docs/using_mailbagit/mailbagit-guided.md
@@ -0,0 +1,130 @@
+---
+layout: page
+title: Mailbagit Guided interface
+permalink: /mailbagit-guided/
+parent: Using mailbagit
+nav_order: 3
+---
+
+# mailbagit "guided" interface
+
+If you are not familiar with the traditional [command-line interface]({{ site.baseurl }}/mailbagit), you can try the `mailbagit-guided` interface. This still uses the command line, but asks you detailed questions and runs `mailbagit` based on your responses.
+
+* [Download and install mailbagit]({{ site.baseurl }}/install).
+* [Download a sample MBOX file]({{ site.baseurl }}/data/account.mbox) for training and testing.
+
+![Screenshot of running the mailbagit-guided interface running on Windows.]({{ site.baseurl }}/img/mailbagit-guided.png)
+
+To use this interface, once mailbagit is [installed]({{ site.baseurl }}/install), you can enter the `mailbagit-guided` command in Terminal on macOS or Linux, or CMD.exe or Powershell on Windows.
+
+If you are using the [Windows executables]({{ site.baseurl }}/exe), you can just double-click on the `mailbagit-guided.exe` file.
+
+Type answers into the prompt and hit "enter". At any prompt you can enter "exit" to stop.
+
+## `mailbagit-guided` workflow
+
+The command line will walk users through a series of prompts to process the emails into archival packages.
+
+1. **Enter the file format you wish to package ([eml](https://www.loc.gov/preservation/digital/formats/fdd/fdd000388.shtml), [mbox](https://www.loc.gov/preservation/digital/formats/fdd/fdd000383.shtml), [msg](https://www.loc.gov/preservation/digital/formats/fdd/fdd000379.shtml), [pst](https://www.loc.gov/preservation/digital/formats/fdd/fdd000378.shtml)):**
+
+    This is the existing email export format that you wish to package into a mailbag.
+
+2. **Enter a path to the MBOX or PST file or a directory containing MBOX, PST, MSG, or EML files:**
+
+    This might look similar to `C:\Users\[username]\Downloads\account.mbox` or `C:\Users\[username]\Documents\emailexports` on Windows, or `/home/[username]/export.pst` or `/home/[username]/path/to/email` on MacOS or Linux. 
+
+    Note that the file path needs to be transcribed exactly. You can also use a path relative to your current working directory.
+
+3. **Enter the derivatives formats to create separated by spaces ([eml](https://www.loc.gov/preservation/digital/formats/fdd/fdd000388.shtml), [html](https://www.loc.gov/preservation/digital/formats/fdd/fdd000475.shtml), [mbox](https://www.loc.gov/preservation/digital/formats/fdd/fdd000383.shtml), pdf-chrome, [pdf](https://www.loc.gov/preservation/digital/formats/fdd/fdd000030.shtml), txt, [warc](https://www.loc.gov/preservation/digital/formats/fdd/fdd000236.shtml)):**
+
+    These are the derivative formats that `mailbagit`will create an include in the mailbag. `pdf-chrome` creates a PDF just like `pdf`, but uses a different dependency too. `chrome --headless` instead of `wkhtmltopdf`. PDF options will not display unless [PDF dependencies are installed]({{ site.baseurl }}/pdf).
+
+4. **Enter a name for the mailbag:**
+
+    This will be the folder or .zip file name for the mailbag.
+
+5. **Would you like to try a dry run? This is a test run that will report errors but not alter your files. (yes, y, no, n):**
+
+    A dry run means that it will attempt to parse the email and begins to generate derivatives, but does not actually save them or do anything to the input data. A dry run does create error and warnings reports. The dry run may take less time to process and can be useful to identify any problems or issues before actually creating a mailbag.
+
+6. **Would you like more options? If no, we will package the mailbag. (yes, y, no, n):**
+
+    Enter yes to answer questions 7-12. No will skip these options and proceed to step 8.
+
+7. **Would you like to include companion files (such as metadata files) that are present in the provided directory?**
+
+    If you entered a directory instead of a single file as a path, using this option will package all files and folders in that directory into the mailbag. If you select no, only the files you selected in option 1 will be included.
+
+8. **Would you like to compress the mailbag? (no, n, zip, tar, tar.gz):**
+
+    A [zip](https://www.loc.gov/preservation/digital/formats/fdd/fdd000354.shtml), [tar](https://www.loc.gov/preservation/digital/formats/fdd/fdd000531.shtml), or tar.gz (compressed tar) makes the mailbag into a single file and zip and tar.gz will make it smaller. If no is selected, `mailbagit` will keep the mailbag as a directory that you can browse.
+
+9. **Would you like to log to a file? ({path/to/file.log}, no, n):**
+
+    If you provide a path to a log file, `mailbagit` will log updates to this file in addition to the command line, so they will be available after you close the prompt. The file provided will be created if it does not already exist.
+
+10. **Would you like to apply custom CSS to HTML and PDF derivatives? ({path/to/file.css}, no, n):**
+
+    Add the path to a `.css` file to customize the styling for HTML and PDF derivatives, or select no.
+
+11. **Mailbagit uses sha256 and sha512 by default to develop preservation manifests. Would you like to customize the checksums used? (yes, y, no, n):**
+
+    [If yes,] **Enter the checksum algorithms to use separated by spaces (sha512, blake2s, md5, sha224, sha256, shake_128, shake_256, sha3_224, sha3_512, sha3_384, sha3_256, sha1, blake2b, sha384)**
+
+    Different checksum algorithms have different strengths and weaknesses.
+
+12. **Do you want to add custom metadata in bag-info.txt? (yes, y, no, n):**
+
+    [If “yes] **Optional Metadata Fields:**
+        
+    **capture-date**
+       
+    **capture-agent**
+        
+    **capture-agent-version**
+
+    **source-organization**
+
+    **organization-address**
+
+    **contact-name**
+
+    **contact-phone**
+
+    **contact-email**
+
+    **external-description**
+
+    **external-identifier**
+
+    **bag-group-identifier**
+
+    **bag-count**
+
+    **internal-sender-identifier**
+
+    **internal-sender-description**
+
+    **bagit-profile-identifier**
+
+    **Enter a field and value separated by colon (:), or enter "done" when complete:**
+
+    To add any of the listed fields to `bag-info.txt`, enter the field, then a colon (:), then the value.
+
+    For example: `source-organization: University at Albany, SUNY`
+
+    You can keep entering fields, and enter `done` when you are finished.
+
+13. After finishing these prompts, it will commence with parsing the emails and creating derivatives. It will show progress for every 1% of messages processed.
+
+        INFO: Reading: .\path\to\account.mbox
+        INFO: Found 331 messages.
+        39.0% [Processed 129 of 331 messages] 7.45s remaining
+        100.0% [Processed 331 of 331 messages] 0.0s remaining
+        INFO: Writing CSV reports...
+        INFO: Finished packaging mailbag.
+        Mailbag finished packaging at .\path\to\mailbag. Press any key to finish.
+
+Pressing a key closes the window, so be sure not the path to the mailbag first. Your mailbag is now complete!
+
+* [What `mailbagit` creates]({{ site.baseurl }}/mailbagit#what-mailbagit-creates)
diff --git a/docs/using_mailbagit/mailbagit.md b/docs/using_mailbagit/mailbagit.md
index 507db80..634a6cc 100755
--- a/docs/using_mailbagit/mailbagit.md
+++ b/docs/using_mailbagit/mailbagit.md
@@ -9,11 +9,11 @@ nav_order: 1
 # Using the `mailbagit` command line utility
 
 
-When `mailbagit` in [installed]({{ site.baseurl }}/install), it makes the `mailbagit` command available in you command line. You should be able to run `mailbagit -h` in Terminal on macOS or Linux, or CMD.exe or Powershell on Windows.
+When `mailbagit` is installed, it makes the `mailbagit` command available in you command line. `mailbagit -h` can run in Terminal on macOS or Linux, or CMD.exe or Powershell on Windows. [Download and install mailbagit]({{ site.baseurl }}/install).
 
 ![Screenshot of running the mailbagit command in Windows Powershell.]({{ site.baseurl }}/img/mailbagit-demo1.png)
 
-You can download a [sample MBOX file]({{ site.baseurl }}/data/account.mbox) for testing.
+[Download a sample MBOX file]({{ site.baseurl }}/data/account.mbox) for training and testing.
 
 ## Examples
 
@@ -34,6 +34,8 @@ mailbagit path/to/directory -i eml -d pdf-chrome warc -mailbag-name fundraising_
 
 > This examples also uses the `-r` (dry-run) flag, which will parse all EML files, but will not move, change, or package them into a mailbag. If there are any parsing errors, this will write an error report to the  `fundraising_emails_errors` directory.
 
+💡 `mailbagit` can also package the .ost file that the Microsoft Outlook desktop client uses to store email locally. OSTs are just treated as PSTs. If you use Outlook, you might be able to find your local OST file at `C:\Users\[username]\AppData\Local\Microsoft\Outlook`.
+
 ## Arguments
 
 The arguments listed below can be entered in the command line when using `mailbagit`or entered in `mailbagit-gui` fields
@@ -88,4 +90,14 @@ If you would like to validate your mailbag, `mailbagit` comes with [bagit-python
 
 ```
 bagit.py --validate /path/to/mailbag
-```
\ No newline at end of file
+```
+
+## What `mailbagit` creates
+
+`mailbagit` creates a "mailbag" according to the [Mailbag Specification]({{ site.baseurl }}/spec). The mailbag will be named using the provided `mailbag_name` and will be a folder, unless compression was used. In this folder, you will find a payload folder called “data” which contains the original export formats, as well as attachments, and any derivatives you selected.
+
+Mailbags also contain one or more `mailbag.csv` files which list all messages that were packaged. The first column is “Error” where any  errors in the packaging are reported. If the message was parsed and generated derivatives successfully, this field will be blank. The Mailbag-Message-ID is a sequential ID number that is unique within a mailbag. This ID is used to name all the derivative files in the mailbag. `mailbag.csv` files also document the number of attachments and contain common email headers such as [Message-ID](https://en.wikipedia.org/wiki/Message-ID), To, From, CC, and Subject.
+
+As valid "bags," according to the [Bagit specification](https://tools.ietf.org/html/rfc8493), mailbags also contain manifests containing checksums for all files within the bag. These checksums are useful to establish fixity or show that the files have not changed over time.
+
+* `mailbagit` also generates detailed [error reports]({{ site.baseurl }}/errors) 
diff --git a/docs/using_mailbagit/plugins.md b/docs/using_mailbagit/plugins.md
index 2d63975..0ef5588 100755
--- a/docs/using_mailbagit/plugins.md
+++ b/docs/using_mailbagit/plugins.md
@@ -3,7 +3,7 @@ layout: page
 title: Plugins
 permalink: /plugins/
 parent: Using mailbagit
-nav_order: 4
+nav_order: 6
 ---
 
 # Plugins
diff --git a/docs/using_mailbagit/warcs.md b/docs/using_mailbagit/warcs.md
new file mode 100644
index 0000000..95caf89
--- /dev/null
+++ b/docs/using_mailbagit/warcs.md
@@ -0,0 +1,58 @@
+---
+layout: page
+title: WARC Derivatives
+permalink: /warcs/
+parent: Using mailbagit
+nav_order: 9
+---
+
+# WARC Derivatives
+
+`mailbagit` can create WARC derivatives for email messages. This is a bit of an experimental feature in that using web archives for email isn't common and there is no concensus yet on how these WARC should be strutured. Still, since WARCs do a good job of both preserving how messages are displayed as well as maintaining email messages as data, they can be really useful for email preservation. Thus, we're including this feature as an example for users to try out and experiment with. If you have thoughts or feedback on this, please [submit an issue](https://github.com/UAlbanyArchives/mailbagit/issues/new/choose)!
+
+WARCs can be more challenging to use that typical files, as you need software to "replay" them. [ReplayWeb.Page](https://replayweb.page/) is an easy method, and you can checkout the [Web Archiving Awesome List](https://github.com/iipc/awesome-web-archiving#replay) for more. The WARC files created by `mailbagit` can also be small enough to view in a text editor. The files are compressed with gzip, so you just have to extract them to plain text.
+
+
+## What's included in WARC derivatives
+
+`mailbagit` creates a compressed WARC file for each email message. Each WARC includes:
+
+* an HTML message body as `body.html` (or plain text if HTML is not present)
+* a `headers.json` file with a complete set of email headers as a response record
+* a `headers.json` file with a complete set of email headers as a metadata record
+* All message attachments
+* All externally-hosted resources such as images, audio, video, or CSS in these tags:
+	* `<img>`
+	* `<link>`
+	* `<object>`
+	* `<source>`
+* Optionally, if the `-l`/`--external-links` argument is provided, `mailbagit` will also crawl and include `<a>` links contained in the message body as well as the externally-hosted resources for those pages with these tags:
+	* `<img>`
+	* `<link>`
+	* `<object>`
+	* `<source>`
+	* `<script>`
+	* `<iframe>`
+	* `<embed>`
+	* `<input>`
+	* `<track>`
+
+## WARC-Target-URI
+
+Since email messages are not publically available on the web, they don't really have URLs like are typically used for [WARC-Target-URI](https://iipc.github.io/warc-specifications/specifications/warc-format/warc-1.1/#warc-target-uri). While a messages's [Message-ID](https://en.wikipedia.org/wiki/Message-ID) could potentially be useful, we have found cases where his headers was stripped or otherwise missing in email exports.
+
+This field is important, as WARCs and replay software use WARC-Target-URI to arrange and provide access to web archives. For example, the Wayback Machine's calendar interface relies on WARC-Target-URIs, as "https://library.albany.edu" is the WARC-Target-URI in [https://web.archive.org/web/20220417153716/https://library.albany.edu/](https://web.archive.org/web/20220417153716/https://library.albany.edu/).
+
+Since WARC-Target-URI is fundamentally necessary to use WARC files, this makes `mailbagit`'s WARC implementation a bit awkward. We decided to use a standard root URI for each WARC record that is derived from an email message: `http://mailbag/[Mailbag-Message-ID]/`. This is problematic, as the URIs are not unique outside of a mailbag, but we didn't really have a better idea. If you have feedback or suggestions, [let us know](https://github.com/UAlbanyArchives/mailbagit/issues/new/choose)!
+
+Thus, these are could be common WARC-Target-URIs generated by `mailbagit`:
+
+```
+http://mailbag/39/body.html
+http://mailbag/39/headers.json
+http://mailbag/39/attachmentFilename.pdf
+```
+
+## 400s and other crawler blocks
+
+`mailbagit` uses Python's `requests` package to crawl externally-hosted resources and include them in WARC derivatives. This is the same method commonly used by web scrapers, which means that many websites try to block crawlers like this. So you may experience HTTP 400 or other blocks, particulatly when using the `-l`/`--external-links` option. These will be documented in the [warnings report]({{ site.baseurl }}/errors/). sWe're hoping to be able to improve web crawling in the future.
diff --git a/mailbagit-guided.py b/mailbagit-guided.py
new file mode 100644
index 0000000..6d1c000
--- /dev/null
+++ b/mailbagit-guided.py
@@ -0,0 +1,3 @@
+from mailbagit import guided
+
+guided()
diff --git a/mailbagit/__init__.py b/mailbagit/__init__.py
index 0ff1547..8c54c9d 100644
--- a/mailbagit/__init__.py
+++ b/mailbagit/__init__.py
@@ -1,23 +1,22 @@
 # __init__.py
 
 # Version of the mailbagit package
-__version__ = "0.3.0"
+__version__ = "0.4.0"
 
 import os
 from pathlib import Path
 from bagit import _make_parser, Bag, BagHeaderAction, DEFAULT_CHECKSUMS
 import importlib
-from structlog import get_logger
-from argparse import ArgumentParser
+from mailbagit.loggerx import setup_logging, get_logger
+from argparse import ArgumentParser, FileType
 from mailbagit.email_account import EmailAccount, import_formats
 from mailbagit.derivative import Derivative, import_derivatives
 from mailbagit.controller import Controller
+from mailbagit.guided import prompts
 import mailbagit.loggerx
 import mailbagit.globals
 
 globals.init()
-loggerx.configure()
-log = get_logger()
 
 if importlib.util.find_spec("gooey"):
     gooeyCheck = True
@@ -40,9 +39,6 @@
 import_formats(plugin_dirs["formats"])
 import_derivatives(plugin_dirs["derivatives"])
 
-log.debug("EmailAccount:", Registry=EmailAccount.registry)
-log.debug("Derivative:", Registry=Derivative.registry)
-
 bagit_parser = _make_parser()
 if gooeyCheck:
     mailbag_parser = GooeyParser(description="Mailbagit")
@@ -53,18 +49,24 @@
 mailbagit_options = mailbag_parser.add_argument_group("Mailbagit options")
 mailbagit_metadata = mailbag_parser.add_argument_group("Optional Mailbag Metadata")
 
+# Get possible hashes and metadata fields from bagit_parser for guided input
+hashes = []
+metadata_fields = []
+
 # Load relevant args from bagit_parser to mailbag_parser
 # This is necessary as mailbagit does not support --validate, --fast, or --completeness-only
 # Checksum args also do not action="store_true" so they don't display as checkboxes with Gooey
 # Excluding log and quiet for not since we're not handling these yet
-exclude_args = ["directory", "help", "validate", "fast", "completeness_only", "log", "quiet"]
+exclude_args = ["directory", "help", "validate", "fast", "completeness_only", "quiet"]
 for arg_group in bagit_parser._action_groups:
     # print(arg_group.__dict__["title"].title())
     group = mailbag_parser.add_argument_group(arg_group.__dict__["title"].title())
     group.description = arg_group.__dict__["description"]
     for i, action in enumerate(arg_group._actions):
         if action.container == arg_group and not action.dest in exclude_args:
-            # print ("\t" + action.dest)
+            # Get possible hashes from bagit_parser for guided input
+            if action.dest == "checksums":
+                hashes.append(action.option_strings[0][2:])
             if action.nargs == 0:
                 # checksum options
                 if gooeyCheck:
@@ -90,16 +92,19 @@
                     )
             else:
                 if arg_group.__dict__["title"].lower() == "optional bag metadata":
-                    group.add_argument(
-                        action.option_strings[0],
-                        required=action.required,
-                        default=action.default,
-                        help=action.help,
-                        type=str,
-                        dest=action.dest,
-                        action=BagHeaderAction,
-                        nargs=action.nargs,
-                    )
+                    # bag_size is automatically set and cannot be overridden
+                    if action.dest != "bag_size":
+                        metadata_fields.append(action.dest.replace("_", "-"))
+                        group.add_argument(
+                            action.option_strings[0],
+                            required=action.required,
+                            default=action.default,
+                            help=action.help,
+                            type=str,
+                            dest=action.dest.replace("_", "-"),
+                            action=BagHeaderAction,
+                            nargs=action.nargs,
+                        )
                 elif action.dest.lower() == "processes":
                     group.add_argument(
                         action.option_strings[0],
@@ -121,6 +126,9 @@
                         dest=action.dest,
                         nargs=action.nargs,
                     )
+    # append json stdout option to optional args from bagit parser
+    if arg_group.__dict__["title"].lower() == "optional arguments":
+        group.add_argument("-j", "--log-json", help="Format logs as JSON", default=False, action="store_true")
 
 
 input_types = list(key for key in EmailAccount.registry.keys() if key != "example")
@@ -140,6 +148,7 @@
 mailbagit_args.add_argument(
     "-i", "--input", required=True, help=f"The email export format to be packaged.", choices=input_types, type=str.lower, nargs=None
 )
+
 if gooeyCheck:
     mailbagit_args.add_argument(
         "-d",
@@ -169,15 +178,19 @@
     "-c", "--compress", help="Compress the mailbag as ZIP, TAR, or TAR.GZ.", nargs=None, choices=["tar", "zip", "tar.gz"]
 )
 mailbagit_options.add_argument(
-    "-r", "--dry_run", help="A dry run performs a trial run with no changes made.", default=False, action="store_true"
+    "-r", "--dry-run", help="A dry run performs a trial run with no changes made.", default=False, action="store_true"
+)
+mailbagit_options.add_argument(
+    "-l", "--external-links", help="Crawl and add external <a> links to WARC derivatives", default=False, action="store_true"
 )
 mailbagit_options.add_argument(
     "-f",
-    "--companion_files",
+    "--companion-files",
     help="Will copy all files in the path provided to mailbagit in to a mailbag regardless of extention.",
     default=False,
     action="store_true",
 )
+
 # Yet-to-be-implemented:
 """
 mailbagit_options.add_argument("--imap_host", help="the host for creating a mailbag from an IMAP connection", nargs=None)
@@ -206,21 +219,35 @@
 
 # Optional user-supplied mailbag metadata
 mailbagit_metadata.add_argument(
-    "--capture-date", help="Timestamp denoting when the email included in a mailbag was originally captured.", nargs=None
+    "--capture-date",
+    help="Timestamp denoting when the email included in a mailbag was originally captured.",
 )
 mailbagit_metadata.add_argument(
-    "--capture-agent", help="A string field describing the agent used to capture the email included in a mailbag.", nargs=None
+    "--capture-agent",
+    help="A string field describing the agent used to capture the email included in a mailbag.",
 )
 mailbagit_metadata.add_argument(
     "--capture-agent-version",
     help="A string field describing the version of the agent used to capture the email included in a mailbag.",
-    nargs=None,
 )
+# Prepend user-supplied mailbag metadata fields to guided input options
+metadata_fields.insert(0, "capture-agent-version")
+metadata_fields.insert(0, "capture-agent")
+metadata_fields.insert(0, "capture-date")
 
 
 def cli():
     """hook for CLI-only mailbagit invocation"""
-    main()
+    args = mailbag_parser.parse_args()
+    main(args)
+
+
+def guided():
+    """hook for Guided CLI mailbagit invocation"""
+    prompts(input_types, derivative_types, hashes, metadata_fields)
+    args = mailbag_parser.parse_args()
+    main(args)
+    input(f"Mailbag finished packaging at { args.path }. Press any key to finish.")
 
 
 if gooeyCheck:
@@ -228,33 +255,26 @@ def cli():
     @Gooey(richtext_controls=True)
     def gui():
         """hook for GUI mailbagit invocation"""
-        main()
+        args = mailbag_parser.parse_args()
+        main(args)
 
 
-def main():
-    args = mailbag_parser.parse_args()
+def main(args):
+    setup_logging(stream_json=args.log_json, filename=args.log)
     args.input = args.input.lower()
 
     if not os.path.exists(args.path[0]):
         error_msg = "Invalid path, does not exist as a file or directory."
         mailbag_parser.error((error_msg))
 
-    """
-    # handle arg errors
-    if args.input not in EmailAccount.registry.keys():
-        error_msg = 'Invalid derivatives, choose from: "' + '", "'.join(EmailAccount.registry.keys()) + '"'
-        mailbag_parser.error((error_msg))
-
-    if isinstance(args.derivatives, str):
-        args.derivatives = args.derivatives.split(" ")
-        if not all(elem in derivative_types for elem in args.derivatives):
-            error_msg = 'Invalid derivatives, choose from: "' + '", "'.join(derivative_types) + '"'
-            mailbag_parser.error((error_msg))
-
     if args.input in args.derivatives:
         error_msg = "Invalid derivatives, mailbagit does not support the source format as a derivative."
         mailbag_parser.error((error_msg))
-    """
+
+    # Check for multiple pdf derivatives, like both pdf and pdf-chrome
+    if ["pdf" in x for x in args.derivatives].count(True) > 1:
+        error_msg = "Invalid derivatives, mailbagit can only use one module to make PDF derivatives"
+        mailbag_parser.error((error_msg))
 
     if args.processes < 1:
         error_msg = "processes must be valid integer > 0"
@@ -269,9 +289,6 @@ def main():
         )
         mailbag_parser.error((error_msg))
 
-    # Okay, if you made it here, args are good!
-    log.debug("Arguments:", args=args)
-
     args.path = args.path[0]
     c = Controller(args)
     return c.generate_mailbag()
diff --git a/mailbagit/controller.py b/mailbagit/controller.py
index 140d67c..9535418 100644
--- a/mailbagit/controller.py
+++ b/mailbagit/controller.py
@@ -1,7 +1,7 @@
 import argparse
 import bagit
 
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 import csv
 import mailbagit
 from mailbagit.email_account import EmailAccount
@@ -10,6 +10,7 @@
 from pathlib import Path
 import os, shutil, glob
 import mailbagit.helper.controller as controller
+import mailbagit.helper.common as common
 import mailbagit.globals as globals
 from time import time
 import uuid
@@ -110,12 +111,14 @@ def generate_mailbag(self):
             # Creating a bagit-python style bag
             bag = bagit.make_bag(mailbag_dir, self.args.bag_info, processes=self.args.processes, checksums=self.args.checksums)
             bag.info["Bag-Type"] = "Mailbag"
-            bag.info["Mailbag-Specification-Version"] = "0.3"
+            bag.info["Mailbag-Specification-Version"] = "1.0"
             bag.info["Mailbag-Source"] = self.args.input.lower()
             bag.info["Original-Included"] = "True"
-            bag.info["External-Identifier"] = uuid.uuid4()
             bag.info["Mailbag-Agent"] = mailbagit.__name__
             bag.info["Mailbag-Agent-Version"] = mailbagit.__version__
+            # Make sure now custom external-idenifier is in args
+            if not "external-identifier" in set(key.lower() for key in self.args.bag_info.keys()):
+                bag.info["External-Identifier"] = uuid.uuid4()
             # user-supplied mailbag metadata
             user_metadata = ["Capture-Date", "Capture-Agent", "Capture-Agent-Version"]
             for user_field in user_metadata:
@@ -126,7 +129,7 @@ def generate_mailbag(self):
             bag.info[self.args.input.upper() + "-Agent-Version"] = mail_account.format_agent_version
 
         # Instantiate derivatives
-        derivatives = [d(mail_account, args=self.args, mailbag_dir=mailbag_dir) for d in self.derivatives_to_create]
+        derivatives = [d(mail_account, self.args, mailbag_dir) for d in self.derivatives_to_create]
         if not self.args.dry_run:
             # write derivatives metadata
             for d in derivatives:
@@ -149,9 +152,8 @@ def generate_mailbag(self):
         warn_csv = [self.csv_headers]
 
         # Count total no. of messages and set start time
-        mail_account.iteration_only = True
-        total_messages = len(list(mail_account.messages()))
-        mail_account.iteration_only = False
+        total_messages = mail_account.number_of_messages
+        log.info(f"Found {total_messages} messages.")
         start_time = time()
 
         for message in mail_account.messages():
@@ -206,7 +208,7 @@ def generate_mailbag(self):
                 # Write Warning Report
                 if len(warn_stack_trace) > 0:
                     if not os.path.isdir(warn_dir):
-                        # making error directory if error is present
+                        # making warn directory if error is present
                         os.mkdir(warn_dir)
                     warn_csv.append(self.message_to_csv(message, "warn"))
                     warn_trace_file = os.path.join(warn_dir, str(message.Mailbag_Message_ID) + ".txt")
@@ -225,10 +227,24 @@ def generate_mailbag(self):
                     mailbag_message_id, total_messages, start_time, prefix="Progress ", suffix="Complete", print_End=print_End
                 )
 
-        # End thread and server for WARC derivatives
-        for d in derivatives:
-            if "warc.WarcDerivative" in str(type(d)):
-                d.terminate()
+        # Write any empty email folders to derivatives subdirectories
+        if "empty_folder_paths" in mail_account.account_data:
+            if not os.path.isdir(warn_dir):
+                # making warn directory if error is present
+                os.mkdir(warn_dir)
+            for empty_folder in mail_account.account_data["empty_folder_paths"]:
+                warn_text = f'Folder "{empty_folder}" did not contain any messages or subfolders.'
+                log.warn(warn_text)
+                warn_trace_file = os.path.join(warn_dir, common.normalizePath(empty_folder).replace("/", "%2F") + ".txt")
+                with open(warn_trace_file, "w", encoding="utf-8") as f:
+                    f.write(warn_text)
+                    f.close()
+                for d in derivatives:
+                    folder_path = common.normalizePath(os.path.join(d.format_subdirectory, empty_folder))
+                    if not self.args.dry_run:
+                        if not os.path.isdir(folder_path):
+                            log.debug("Writing empty folder " + str(folder_path))
+                            os.makedirs(folder_path)
 
         # append any remaining csv portions < 100000
         csv_data.append(csv_portion)
@@ -254,7 +270,7 @@ def generate_mailbag(self):
                         writer.writerows(portion)
                         f.close()
 
-        controller.progressMessage("Writing CSVs...")
+        log.info("Writing CSV reports...")
         log.debug("Writing error.csv to " + str(error_dir))
         if len(error_csv) > 1:
             filename = os.path.join(error_dir, "error.csv")
@@ -269,6 +285,7 @@ def generate_mailbag(self):
                 writer.writerows(warn_csv)
 
         if not self.args.dry_run:
+            log.info("Saving manifests...")
             bag_size = 0
             for root, dirs, files in os.walk(os.path.join(str(mailbag_dir), "data")):
                 for file in files:
@@ -282,8 +299,7 @@ def generate_mailbag(self):
             bag.save(manifests=True)
 
         if self.args.compress and not self.args.dry_run:
-            controller.progressMessage("Compressing mailbag...")
-            log.info("Compressing Mailbag")
+            log.info("Compressing mailbag...")
             compressionFormats = {"tar": "tar", "zip": "zip", "tar.gz": "gztar"}
             shutil.make_archive(mailbag_dir, compressionFormats[self.args.compress], mailbag_dir)
 
@@ -292,6 +308,7 @@ def generate_mailbag(self):
                 # Deleting the mailbag if compressed files are present
                 shutil.rmtree(mailbag_dir)
 
-        controller.progressMessage("Finished packaging mailbag.", print_End="\n")
+        #controller.progressMessage("", print_End="\n")
+        log.info("Finished packaging mailbag.")
 
         return mail_account.messages()
diff --git a/mailbagit/derivative.py b/mailbagit/derivative.py
index 244dbc3..c5f32c1 100644
--- a/mailbagit/derivative.py
+++ b/mailbagit/derivative.py
@@ -1,4 +1,4 @@
-from os import listdir
+from os import listdir, makedirs
 from os.path import basename, dirname, exists, isfile, join
 from importlib.machinery import SourceFileLoader
 import sys
@@ -31,12 +31,15 @@ def __init_subclass__(cls, **kwargs):
         __class__.registry[cls.derivative_name] = cls
 
     @abstractmethod
-    def __init__(self, email_account, **kwargs):
+    def __init__(self, args, mailbag_dir):
         """Set up email account for use in processing methods.
 
         If not taking any other steps, the implementation of this can simple be `super()`
         """
-        self.account = email_account
+        self.args = args
+        self.format_subdirectory = join(mailbag_dir, "data", self.derivative_format)
+        if not args.dry_run:
+            makedirs(self.format_subdirectory)
 
     @abstractmethod
     def do_task_per_account(self):
diff --git a/mailbagit/derivatives/eml.py b/mailbagit/derivatives/eml.py
index 503992b..830a12e 100644
--- a/mailbagit/derivatives/eml.py
+++ b/mailbagit/derivatives/eml.py
@@ -5,7 +5,7 @@
 import os, glob
 import mailbox
 from mailbagit.email_account import EmailAccount
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 from email.mime.multipart import MIMEMultipart
 from email.mime.base import MIMEBase
 from email.mime.text import MIMEText
@@ -24,15 +24,11 @@ class EmlDerivative(Derivative):
     derivative_agent = "email"
     derivative_agent_version = platform.python_version()
 
-    def __init__(self, email_account, **kwargs):
-        log.debug("Setup account")
-        super()
+    def __init__(self, email_account, args, mailbag_dir):
+        log.debug(f"Setup {self.derivative_name} derivatives")
 
-        self.args = kwargs["args"]
-        mailbag_dir = kwargs["mailbag_dir"]
-        self.eml_dir = os.path.join(mailbag_dir, "data", self.derivative_format)
-        if not self.args.dry_run:
-            os.makedirs(self.eml_dir)
+        # Sets up self.format_subdirectory
+        super().__init__(args, mailbag_dir)
 
     def do_task_per_account(self):
         print(self.account.account_data())
@@ -42,8 +38,10 @@ def do_task_per_message(self, message):
         errors = []
         try:
 
-            out_dir = os.path.join(self.eml_dir, message.Derivatives_Path)
-            filename = os.path.join(out_dir, str(message.Mailbag_Message_ID))
+            out_dir = os.path.join(self.format_subdirectory, message.Derivatives_Path)
+            filename = os.path.join(out_dir, str(message.Mailbag_Message_ID) + ".eml")
+            errors = common.check_path_length(out_dir, errors)
+            errors = common.check_path_length(filename, errors)
 
             # Build msg
             if not message.Message and not message.Headers:
@@ -65,13 +63,13 @@ def do_task_per_message(self, message):
                         if not self.args.dry_run:
                             if not os.path.isdir(out_dir):
                                 os.makedirs(out_dir)
-                            with open(filename + ".eml", "w", encoding=out_encoding) as outfile:
+                            with open(filename, "w", encoding=out_encoding) as outfile:
                                 gen = generator.Generator(outfile)
                                 gen.flatten(msg)
                                 outfile.close()
                             fullObjectWrite = True
                     except Exception as e:
-                        derivative.deleteFile(filename + ".eml")
+                        derivative.deleteFile(filename)
                         desc = "Error writing full email object to EML, generating it from the model instead"
                         errors = common.handle_error(errors, e, desc, "warn")
                         fullObjectWrite = False
@@ -124,7 +122,7 @@ def do_task_per_message(self, message):
                                 mimeType = attachment.MimeType
                                 if mimeType is None:
                                     mimeType = "application/octet-stream"
-                                    desc = "Mime type not found for attachment, set as " + mimeType
+                                    desc = "Mime type not found for attachment. For EML, set as " + mimeType
                                     errors = common.handle_error(errors, None, desc, "error")
                                 mimeType = mimeType.split("/")
                                 part = MIMEBase(mimeType[0], mimeType[1])
@@ -143,7 +141,7 @@ def do_task_per_message(self, message):
                                     content_disposition = "attachment"
                                     part.add_header("Content-ID", attachment.Content_ID)
 
-                                part.add_header("Content-Disposition", content_disposition, filename=attachment.Name)
+                                part.add_header("Content-Disposition", content_disposition, filename=attachment.WrittenName)
                                 msg.attach(part)
                         except Exception as e:
                             desc = "Error writing attachment(s) to EML derivative"
@@ -155,7 +153,7 @@ def do_task_per_message(self, message):
                             if not self.args.dry_run:
                                 if not os.path.isdir(out_dir):
                                     os.makedirs(out_dir)
-                                with open(filename + ".eml", "w", encoding="utf-8") as outfile:
+                                with open(filename, "w", encoding="utf-8") as outfile:
                                     gen = generator.Generator(outfile)
                                     gen.flatten(msg)
                                     outfile.close()
diff --git a/mailbagit/derivatives/example.py b/mailbagit/derivatives/example.py
index 7edf6d6..8350079 100644
--- a/mailbagit/derivatives/example.py
+++ b/mailbagit/derivatives/example.py
@@ -1,6 +1,6 @@
 # This is an example derivative, meant to show how
 # to hook up a real parser
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
@@ -14,16 +14,19 @@ class ExampleDerivative(Derivative):
     derivative_agent = ""
     derivative_agent_version = ""
 
-    def __init__(self, email_account, **kwargs):
-        log.debug("Setup account")
-        super()
+    def __init__(self, email_account, args, mailbag_dir):
+        log.debug(f"Setup {self.derivative_name} derivatives")
+
+        # Sets up self.format_subdirectory
+        super().__init__(args, mailbag_dir)
 
     def do_task_per_account(self):
         print(self.account.account_data())
 
     def do_task_per_message(self, message):
+        print(self.format_subdirectory)
         if message.Message_ID:
-            log.debug(message.Message_ID.strip())
+            log.debug(message.Message_ID)
         elif message.Subject:
             log.debug(message.Subject)
 
diff --git a/mailbagit/derivatives/html.py b/mailbagit/derivatives/html.py
index 870daed..e430888 100644
--- a/mailbagit/derivatives/html.py
+++ b/mailbagit/derivatives/html.py
@@ -2,7 +2,7 @@
 import os
 import mailbagit.helper.derivative as derivative
 import mailbagit.helper.common as common
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
@@ -15,15 +15,11 @@ class HtmlDerivative(Derivative):
     derivative_agent = ""
     derivative_agent_version = ""
 
-    def __init__(self, email_account, **kwargs):
-        log.debug("Setup account")
-        super()
+    def __init__(self, email_account, args, mailbag_dir):
+        log.debug(f"Setup {self.derivative_name} derivatives")
 
-        self.args = kwargs["args"]
-        mailbag_dir = kwargs["mailbag_dir"]
-        self.html_dir = os.path.join(mailbag_dir, "data", self.derivative_format)
-        if not self.args.dry_run:
-            os.makedirs(self.html_dir)
+        # Sets up self.format_subdirectory
+        super().__init__(args, mailbag_dir)
 
     def do_task_per_account(self):
         log.debug(self.account.account_data())
@@ -34,10 +30,12 @@ def do_task_per_message(self, message):
         try:
 
             if message.Derivatives_Path is None:
-                out_dir = self.html_dir
+                out_dir = self.format_subdirectory
             else:
-                out_dir = os.path.join(self.html_dir, message.Derivatives_Path)
+                out_dir = os.path.join(self.format_subdirectory, message.Derivatives_Path)
+            errors = common.check_path_length(out_dir, errors)
             filename = os.path.join(out_dir, str(message.Mailbag_Message_ID) + ".html")
+            errors = common.check_path_length(filename, errors)
 
             if message.HTML_Body is None and message.Text_Body is None:
                 desc = "No HTML or plain text body for " + str(message.Mailbag_Message_ID) + ", no HTML derivative created"
diff --git a/mailbagit/derivatives/mbox.py b/mailbagit/derivatives/mbox.py
index 308e3cf..1d31eeb 100644
--- a/mailbagit/derivatives/mbox.py
+++ b/mailbagit/derivatives/mbox.py
@@ -1,4 +1,4 @@
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 import mailbox
 import os
 import mailbagit.helper.common as common
@@ -20,15 +20,11 @@ class MboxDerivative(Derivative):
     derivative_agent = mailbox.__name__
     derivative_agent_version = platform.python_version()
 
-    def __init__(self, email_account, **kwargs):
-        log.debug("Setup account")
-        super()
+    def __init__(self, email_account, args, mailbag_dir):
+        log.debug(f"Setup {self.derivative_name} derivatives")
 
-        self.args = kwargs["args"]
-        mailbag_dir = kwargs["mailbag_dir"]
-        self.mbox_dir = os.path.join(mailbag_dir, "data", self.derivative_format)
-        if not self.args.dry_run:
-            os.makedirs(self.mbox_dir)
+        # Sets up self.format_subdirectory
+        super().__init__(args, mailbag_dir)
 
     def do_task_per_account(self):
         log.debug(self.account.account_data())
@@ -40,14 +36,16 @@ def do_task_per_message(self, message):
 
             try:
                 if len(message.Derivatives_Path) < 1:
-                    out_dir = self.mbox_dir
+                    out_dir = self.format_subdirectory
                     filename = os.path.join(out_dir, self.args.mailbag_name + ".mbox")
                 elif len(message.Derivatives_Path.strip("/").split("/")) == 1:
-                    out_dir = self.mbox_dir
+                    out_dir = self.format_subdirectory
                     filename = os.path.join(out_dir, message.Derivatives_Path.strip("/") + ".mbox")
                 else:
-                    out_dir = os.path.join(self.mbox_dir, os.path.dirname(message.Derivatives_Path.strip("/")))
+                    out_dir = os.path.join(self.format_subdirectory, os.path.dirname(message.Derivatives_Path.strip("/")))
                     filename = os.path.join(out_dir, os.path.basename(message.Derivatives_Path.strip(os.sep)) + ".mbox")
+                errors = common.check_path_length(out_dir, errors)
+                errors = common.check_path_length(filename, errors)
             except Exception as e:
                 desc = "Error setting up MBOX derivative"
                 errors = common.handle_error(errors, e, desc)
@@ -122,7 +120,7 @@ def do_task_per_message(self, message):
                                 mimeType = attachment.MimeType
                                 if mimeType is None:
                                     mimeType = "text/plain"
-                                    log.warn("Mime type not found for the attachment. Set as " + mimeType + ".")
+                                    log.warn("Mime type not found for the attachment. For MBOX, set as " + mimeType + ".")
                                 mimeType = mimeType.split("/")
                                 part = MIMEBase(mimeType[0], mimeType[1])
                                 part.set_payload(attachment.File)
@@ -140,7 +138,7 @@ def do_task_per_message(self, message):
                                     content_disposition = "attachment"
                                     part.add_header("Content-ID", attachment.Content_ID)
 
-                                part.add_header("Content-Disposition", content_disposition, filename=attachment.Name)
+                                part.add_header("Content-Disposition", content_disposition, filename=attachment.WrittenName)
                                 msg.attach(part)
                         except Exception as e:
                             desc = "Error writing attachment(s) to MBOX derivative"
diff --git a/mailbagit/derivatives/pdf.py b/mailbagit/derivatives/pdf.py
index b1b686e..a143fe4 100644
--- a/mailbagit/derivatives/pdf.py
+++ b/mailbagit/derivatives/pdf.py
@@ -2,7 +2,7 @@
 import subprocess
 import distutils.spawn
 from mailbagit.derivative import Derivative
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 import mailbagit.helper.derivative as derivative
 import mailbagit.helper.common as common
 
@@ -35,15 +35,11 @@ class PDFDerivative(Derivative):
             raise Exception("Unable to access --version for " + derivative_agent + ": " + err.decode("utf-8"))
         derivative_agent_version = out.decode("utf-8").strip()
 
-        def __init__(self, email_account, **kwargs):
-            log.debug("Setup account")
-            super()
+        def __init__(self, email_account, args, mailbag_dir):
+            log.debug(f"Setup {self.derivative_name} derivatives")
 
-            self.args = kwargs["args"]
-            mailbag_dir = kwargs["mailbag_dir"]
-            self.pdf_dir = os.path.join(mailbag_dir, "data", self.derivative_format)
-            if not self.args.dry_run:
-                os.makedirs(self.pdf_dir)
+            # Sets up self.format_subdirectory
+            super().__init__(args, mailbag_dir)
 
         def do_task_per_account(self):
             print(self.account.account_data())
@@ -54,10 +50,12 @@ def do_task_per_message(self, message):
 
             try:
 
-                out_dir = os.path.join(self.pdf_dir, message.Derivatives_Path)
+                out_dir = os.path.join(self.format_subdirectory, message.Derivatives_Path)
                 filename = os.path.join(out_dir, str(message.Mailbag_Message_ID))
+                errors = common.check_path_length(out_dir, errors)
                 html_name = filename + ".html"
                 pdf_name = filename + ".pdf"
+                errors = common.check_path_length(pdf_name, errors)
 
                 if message.HTML_Body is None and message.Text_Body is None:
                     desc = "No HTML or plain text body for " + str(message.Mailbag_Message_ID) + ", no PDF derivative created"
diff --git a/mailbagit/derivatives/pdf_chrome.py b/mailbagit/derivatives/pdf_chrome.py
index 270acde..18dbfda 100644
--- a/mailbagit/derivatives/pdf_chrome.py
+++ b/mailbagit/derivatives/pdf_chrome.py
@@ -2,7 +2,7 @@
 import subprocess
 import distutils.spawn
 from mailbagit.derivative import Derivative
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 import mailbagit.helper.derivative as derivative
 import mailbagit.helper.common as common
 
@@ -27,15 +27,11 @@ class PDFChromeDerivative(Derivative):
         derivative_agent = "chrome"
         derivative_agent_version = "unknown"
 
-        def __init__(self, email_account, **kwargs):
-            log.debug("Setup account")
-            super()
+        def __init__(self, email_account, args, mailbag_dir):
+            log.debug(f"Setup {self.derivative_name} derivatives")
 
-            self.args = kwargs["args"]
-            mailbag_dir = kwargs["mailbag_dir"]
-            self.pdf_dir = os.path.join(mailbag_dir, "data", self.derivative_format)
-            if not self.args.dry_run:
-                os.makedirs(self.pdf_dir)
+            # Sets up self.format_subdirectory
+            super().__init__(args, mailbag_dir)
 
         def do_task_per_account(self):
             print(self.account.account_data())
@@ -45,10 +41,12 @@ def do_task_per_message(self, message):
             errors = []
             try:
 
-                out_dir = os.path.join(self.pdf_dir, message.Derivatives_Path)
+                out_dir = os.path.join(self.format_subdirectory, message.Derivatives_Path)
                 filename = os.path.join(out_dir, str(message.Mailbag_Message_ID))
+                errors = common.check_path_length(out_dir, errors)
                 html_name = filename + ".html"
                 pdf_name = filename + ".pdf"
+                errors = common.check_path_length(pdf_name, errors)
 
                 if message.HTML_Body is None and message.Text_Body is None:
                     log.warn("No HTML or plain text body for " + str(message.Mailbag_Message_ID) + ". No PDF derivative will be created.")
diff --git a/mailbagit/derivatives/txt.py b/mailbagit/derivatives/txt.py
index ebc73fe..2242054 100644
--- a/mailbagit/derivatives/txt.py
+++ b/mailbagit/derivatives/txt.py
@@ -1,7 +1,7 @@
 # Makes txt file derivatives just containing message bodies
 import os
 import mailbagit.helper.common as common
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
@@ -14,15 +14,11 @@ class TxtDerivative(Derivative):
     derivative_agent = ""
     derivative_agent_version = ""
 
-    def __init__(self, email_account, **kwargs):
-        log.debug("Setup account")
-        super()
+    def __init__(self, email_account, args, mailbag_dir):
+        log.debug(f"Setup {self.derivative_name} derivatives")
 
-        self.args = kwargs["args"]
-        mailbag_dir = kwargs["mailbag_dir"]
-        self.txt_dir = os.path.join(mailbag_dir, "data", self.derivative_format)
-        if not self.args.dry_run:
-            os.makedirs(self.txt_dir)
+        # Sets up self.format_subdirectory
+        super().__init__(args, mailbag_dir)
 
     def do_task_per_account(self):
         log.debug(self.account.account_data())
@@ -32,8 +28,10 @@ def do_task_per_message(self, message):
         errors = []
         try:
 
-            out_dir = os.path.join(self.txt_dir, message.Derivatives_Path)
+            out_dir = os.path.join(self.format_subdirectory, message.Derivatives_Path)
             filename = os.path.join(out_dir, str(message.Mailbag_Message_ID) + ".txt")
+            errors = common.check_path_length(out_dir, errors)
+            errors = common.check_path_length(filename, errors)
 
             if message.Text_Body is None:
                 desc = "No plain text body for " + str(message.Mailbag_Message_ID) + ", no TXT derivative created"
diff --git a/mailbagit/derivatives/warc.py b/mailbagit/derivatives/warc.py
index 225deaf..de8a07a 100644
--- a/mailbagit/derivatives/warc.py
+++ b/mailbagit/derivatives/warc.py
@@ -1,16 +1,23 @@
 import os
 import warcio
+import cssutils
+import logging
 import mailbagit.helper.derivative as derivative
 import mailbagit.helper.common as common
+import mailbagit.helper.format as format
 from warcio.capture_http import capture_http
 from warcio import WARCWriter
 from warcio.statusandheaders import StatusAndHeaders
+from warcio.timeutils import datetime_to_http_date
 import requests  # requests *must* be imported after capture_http
-from threading import Thread
-import http.server
-import socketserver
+import json
+import urllib.parse
+from io import BytesIO
+from bs4 import BeautifulSoup
+from urllib.parse import quote_plus
+from datetime import datetime
 
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
@@ -27,38 +34,98 @@ class WarcDerivative(Derivative):
     derivative_agent = warcio.__name__
     derivative_agent_version = metadata.version("warcio")
 
-    def __init__(self, email_account, **kwargs):
-        log.debug("Setup account")
-        super()
+    def __init__(self, email_account, args, mailbag_dir):
+        log.debug(f"Setup {self.derivative_name} derivatives")
 
-        self.args = kwargs["args"]
-        mailbag_dir = kwargs["mailbag_dir"]
-        self.warc_dir = os.path.join(mailbag_dir, "data", self.derivative_format)
-        self.httpd = []
-        self.port = 5000
-        self.tmp_file = "tmp.html"
+        # Sets up self.format_subdirectory
+        super().__init__(args, mailbag_dir)
 
-        if not self.args.dry_run:
-            os.makedirs(self.warc_dir)
+    def email_external_resources(self, soup):
+        """
+        Reads an HTML body string and looks for all externally-hosted
+        resources in tags that are supported by email clients
 
-            self.server_thread = Thread(target=derivative.startServer, args=(self.args.dry_run, self.httpd, self.port))
-            # Make it a daemon so it will stop after ctrl+c
-            self.server_thread.daemon = True
-            self.server_thread.start()
+        Parameters:
+            soup(obj): A BeautifulSoup object
 
-    def terminate(self):
+        Returns:
+            List: A list of URLs
+        """
+        external_urls = []
+        external_resources = {"img": "src", "link": "href", "object": "data", "source": "src"}
+        for tag in external_resources.keys():
+            attr = external_resources[tag]
+            for tag in soup.findAll(tag):
+                if tag.get(attr) and tag.get(attr).lower().strip().startswith("http"):
+                    external_urls.append(tag.get(attr))
 
-        # Terminate the process
-        try:
-            if not self.args.dry_run:
-                derivative.stopServer(self.args.dry_run, self.httpd[0])
-                self.server_thread.join()
-        except SystemExit:
-            pass
-        except:
-            import traceback
+        return external_urls
+
+    def html_external_resources(self, soup):
+        """
+        Reads an HTML body string and looks for all externally-hosted resources
+
+        Parameters:
+            soup(obj): A BeautifulSoup object
+
+        Returns:
+            List: A list of URLs
+        """
+        external_urls = []
+        # not sure if this is comprehensive but something like "for tag in soup.find_all()"
+        # was waaay too slow
+        external_resources = {
+            "img": "src",
+            "link": "href",
+            "area": "href",
+            "base": "href",
+            "object": "data",
+            "source": "src",
+            "script": "src",
+            "iframe": "src",
+            "embed": "src",
+            "input": "src",
+            "track": "src",
+        }
+        for tag in external_resources.keys():
+            attr = external_resources[tag]
+            for tag in soup.findAll(tag):
+                if tag.get(attr) and tag.get(attr).lower().strip().startswith("http"):
+                    external_urls.append(tag.get(attr))
+
+        return external_urls
 
-            traceback.print_exc()
+    def css_external_resources(self, cssText, cssURL):
+        """
+        Reads an CSS text and looks for all externally-hosted resources
+        in properties, such as "@import url()" or "background-image: url()"
+
+        Parameters:
+            cssText(str): A string of CSS
+
+        Returns:
+            List: A list of URLs
+        """
+
+        external_urls = []
+        cssutils.log.setLevel(logging.CRITICAL)
+        css = cssutils.parseString(cssText)
+        for rule in css:
+            if hasattr(rule, "href"):
+                external_urls.append(rule.href)
+            elif hasattr(rule, "style"):
+                for prop in rule.style:
+                    value = prop.value.lower().strip()
+                    if value.startswith("url(") and value.endswith(")"):
+                        url = prop.value[4:][:-1]
+                        if url.lower().startswith('"data'):
+                            pass
+                        elif url.lower().startswith("http"):
+                            external_urls.append(url)
+                        else:
+                            external_urls.append(urllib.parse.urljoin(cssURL, url))
+
+        return external_urls
 
     def do_task_per_account(self):
         log.debug(self.account.account_data())
@@ -73,63 +140,156 @@ def do_task_per_message(self, message):
                 desc = "No HTML or plain text body for " + str(message.Mailbag_Message_ID) + ", no WARC derivative created"
                 errors = common.handle_error(errors, None, desc, "warn")
             else:
-                out_dir = os.path.join(self.warc_dir, message.Derivatives_Path)
+                out_dir = os.path.join(self.format_subdirectory, message.Derivatives_Path)
                 filename = os.path.join(out_dir, str(message.Mailbag_Message_ID) + ".warc.gz")
+                errors = common.check_path_length(out_dir, errors)
+                errors = common.check_path_length(filename, errors)
                 log.debug("Writing WARC to " + str(filename))
 
+                # This is used for the WARC-Target-URI in the WARC derivatives
+                warc_uri = f"http://mailbag/{str(message.Mailbag_Message_ID)}"
+
+                # Write Headers to UTF-8 JSON
+                try:
+                    headers = {}
+                    for key in message.Headers:
+                        headers[key], errors = format.parse_header(message.Headers[key], errors)
+                    headers_json = json.dumps(headers, indent=4, sort_keys=True).encode("utf-8")
+                except Exception as e:
+                    desc = "Error formatting headers as UTF-8 JSON"
+                    errors = common.handle_error(errors, e, desc)
+
+                # Format HTML for WARC file
+                try:
+                    html_formatted, encoding = derivative.htmlFormatting(message, self.args.css, headers=False)
+                except Exception as e:
+                    desc = "Error formatting HTML for WARC derivative"
+                    errors = common.handle_error(errors, e, desc)
+
+                # Parse HTML for external resources
+                soup = BeautifulSoup(html_formatted, "html.parser")
+                external_urls = self.email_external_resources(soup)
+                # If external links option is selected, also crawl <a> urls and their external resources
+                if self.args.external_links:
+                    for a_tag in soup.findAll("a"):
+                        if a_tag.get("href") and a_tag.get("href").lower().strip().startswith("http"):
+                            external_urls.append(a_tag.get("href"))
+
                 if not self.args.dry_run:
                     if not os.path.isdir(out_dir):
                         os.makedirs(out_dir)
 
                     with open(filename, "wb") as output:
-
+                        writer = WARCWriter(output, gzip=True)
+                        # Write HTML Body
                         try:
-                            html_formatted, encoding = derivative.htmlFormatting(message, self.args.css, headers=False)
-                            with open(self.tmp_file, "w", encoding="utf-8") as f:
-                                f.write(html_formatted)
-                                f.close()
+                            headers_list = [
+                                ("Content-Type", 'text/html; charset="utf-8"'),
+                                ("Date", datetime_to_http_date(datetime.now())),
+                                ("Content-Length", str(len(html_formatted.encode("utf-8")))),
+                            ]
+                            if message.Date:
+                                headers_list.append(("Last-Modified", message.Date))
+                            http_headers = StatusAndHeaders("200 OK", headers_list, protocol="HTTP/1.0")
+                            record = writer.create_warc_record(
+                                f"{warc_uri}/body.html",
+                                "response",
+                                payload=BytesIO(html_formatted.encode("utf-8")),
+                                length=len(html_formatted.encode("utf-8")),
+                                http_headers=http_headers,
+                                warc_content_type="text/html",
+                            )
+                            writer.write_record(record)
                         except Exception as e:
-                            desc = "Error formatting HTML for WARC derivative"
+                            desc = "Error creating WARC response record for HTML body"
                             errors = common.handle_error(errors, e, desc)
 
-                        writer = WARCWriter(output, gzip=True)
+                        # Write external resources
                         try:
-                            resp = requests.get(
-                                "http://localhost:" + str(self.port) + "/" + self.tmp_file,
-                                headers={"Accept-Encoding": "identity"},
-                                stream=True,
-                            )
-                            resp.raise_for_status()
-
-                        except requests.exceptions.HTTPError as e:
-                            desc = "Error requesting HTML for WARC derivative"
+                            s = requests.Session()
+                            request_headers = {
+                                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36"
+                            }
+                            i = 0
+                            while i < len(external_urls):
+                                log.debug("capturing " + external_urls[i])
+                                with capture_http(writer):
+                                    # First try with SSL verification. If fails, raise a warning and turn off
+                                    try:
+                                        r = s.get(external_urls[i], headers=request_headers)
+                                        if r.status_code != 200:
+                                            desc = f"When writing WARC derivative, HTTP {r.status_code} {r.reason} for external resource {external_urls[i]}"
+                                            errors = common.handle_error(errors, None, desc, "warn")
+                                        if "content-type" in r.headers.keys():
+                                            if r.headers["content-type"] == "text/html":
+                                                # Gotta get these external resources as well
+                                                new_soup = BeautifulSoup(r.text, "html.parser")
+                                                new_external_urls = self.html_external_resources(new_soup)
+                                                external_urls.extend(new_external_urls)
+                                            elif r.headers["content-type"] == "text/css":
+                                                new_external_urls = self.css_external_resources(r.text, r.url)
+                                                external_urls.extend(new_external_urls)
+                                    except Exception as e:
+                                        desc = f"Failed to request external URL for WARC derivatives ({external_urls[i]})"
+                                        errors = common.handle_error(errors, e, desc)
+                                i += 1
+                        except Exception as e:
+                            desc = "Error capturing external URL in WARC derivative"
                             errors = common.handle_error(errors, e, desc)
 
+                        # Write attachments
                         try:
-                            # get raw headers from urllib3
-                            headers_list = resp.raw.headers.items()
+                            for i, attachment in enumerate(message.Attachments):
+                                headers_list = [
+                                    ("Content-Type", attachment.MimeType),
+                                    ("Content-ID", attachment.Content_ID),
+                                    ("Filename", attachment.WrittenName),
+                                    ("Content-Length", str(len(attachment.File))),
+                                    ("Date", datetime_to_http_date(datetime.now())),
+                                ]
+                                http_headers = StatusAndHeaders("200 OK", headers_list, protocol="HTTP/1.0")
+                                record = writer.create_warc_record(
+                                    f"{warc_uri}/{quote_plus(attachment.WrittenName)}",
+                                    "response",
+                                    payload=BytesIO(attachment.File),
+                                    length=len(attachment.File),
+                                    http_headers=http_headers,
+                                    warc_content_type="text/html",
+                                )
+                                writer.write_record(record)
+                        except Exception as e:
+                            desc = "Error adding attachments to WARC derivative"
+                            errors = common.handle_error(errors, e, desc)
 
+                        # Write headers
+                        try:
+                            headers_list = [
+                                ("Content-Type", "application/json"),
+                                ("Date", datetime_to_http_date(datetime.now())),
+                                ("Content-Length", str(len(headers_json))),
+                            ]
                             http_headers = StatusAndHeaders("200 OK", headers_list, protocol="HTTP/1.0")
-
                             record = writer.create_warc_record(
-                                "http://localhost:" + str(self.port) + "/" + self.tmp_file,
+                                f"{warc_uri}/headers.json",
                                 "response",
-                                payload=resp.raw,
+                                payload=BytesIO(headers_json),
+                                length=len(headers_json),
                                 http_headers=http_headers,
+                                warc_content_type="application/json",
+                            )
+                            writer.write_record(record)
+                            record = writer.create_warc_record(
+                                f"{warc_uri}/headers.json",
+                                "metadata",
+                                payload=BytesIO(headers_json),
+                                length=len(headers_json),
+                                warc_content_type="application/json",
                             )
-                        except Exception as e:
-                            desc = "Error writing WARC headers"
-                            errors = common.handle_error(errors, e, desc)
-
-                        try:
                             writer.write_record(record)
                         except Exception as e:
-                            desc = "Error writing WARC derivative"
+                            desc = "Error creating JSON metadata record to WARC derivative"
                             errors = common.handle_error(errors, e, desc)
 
-                    output.close()
-                    derivative.deleteFile(self.tmp_file)
-
         except Exception as e:
             desc = "Error creating WARC derivative"
             errors = common.handle_error(errors, e, desc)
diff --git a/mailbagit/email_account.py b/mailbagit/email_account.py
index 864a65d..4f4a7b6 100644
--- a/mailbagit/email_account.py
+++ b/mailbagit/email_account.py
@@ -2,7 +2,7 @@
 from os.path import basename, dirname, exists, isfile, join
 from importlib.machinery import SourceFileLoader
 
-from abc import ABC, abstractmethod
+from abc import ABC, abstractmethod, abstractproperty
 
 
 class EmailAccount(ABC):
@@ -32,13 +32,20 @@ def __init_subclass__(cls, **kwargs):
 
     @abstractmethod
     def __init__(self, target_account, args, **kwargs):
-        """Implement a method that finds and sets up an account so that `account_data` and `messages` can
-        be called on it."""
+        """Implement a method that finds and sets up an account.  The following methods and properties should be present:
+        - `account_data` (property)
+        - `number_of_messages` (property)
+        - `messages` (method)_and `messages`"""
         pass
 
-    @abstractmethod
+    @abstractproperty
     def account_data(self):
-        """Return a dict containing any data about the account as a whole that's relevant to mailbagit processing."""
+        """property giving access to  a dict containing any data about the account as a whole that's relevant to mailbagit processing.  Suggest locating value at  self._account_data"""
+        pass
+
+    @abstractproperty
+    def number_of_messages(self):
+        """property that returns the number of messages in an email account"""
         pass
 
     @abstractmethod
diff --git a/mailbagit/formats/eml.py b/mailbagit/formats/eml.py
index bb83a90..d578585 100644
--- a/mailbagit/formats/eml.py
+++ b/mailbagit/formats/eml.py
@@ -3,13 +3,14 @@
 from pathlib import Path
 import mailbagit.helper.format as format
 import mailbagit.helper.common as common
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 from email import parser
 from mailbagit.email_account import EmailAccount
 from mailbagit.models import Email, Attachment
 import email
 import os
 import platform
+from itertools import chain
 
 log = get_logger()
 
@@ -24,40 +25,54 @@ class EML(EmailAccount):
     def __init__(self, target_account, args, **kwargs):
         log.debug("Parsity parse")
         # code goes here to set up mailbox and pull out any relevant account_data
-        account_data = {}
+        self._account_data = {}
 
         self.path = target_account
         self.dry_run = args.dry_run
         self.mailbag_name = args.mailbag_name
         self.companion_files = args.companion_files
-        self.iteration_only = False
-        log.info("Reading : ", Path=self.path)
 
+        log.info("Reading: " + self.path)
+
+    @property
     def account_data(self):
-        return account_data
+        return self._account_data
 
-    def messages(self):
+    @property
+    def number_of_messages(self):
+        if os.path.isfile(self.path):
+            return 1
+        count = 0
+        for _ in chain((files for _, _, files in os.walk(self.path))):
+            count += 1
+        return count
 
-        fileList = []
+    def messages(self):
         companion_files = []
-        for root, dirs, files in os.walk(self.path):
-            for file in files:
-                mailbag_path = os.path.join(self.path, self.mailbag_name) + os.sep
-                fileRoot = root + os.sep
-                # don't count the newly-created mailbag
-                if not fileRoot.startswith(mailbag_path):
-                    if file.lower().endswith("." + self.format_name):
-                        fileList.append(os.path.join(root, file))
-                    elif self.companion_files:
-                        companion_files.append(os.path.join(root, file))
+        if os.path.isfile(self.path):
+            parent_dir = os.path.dirname(self.path)
+            fileList = [self.path]
+        else:
+            parent_dir = self.path
+            fileList = []
+            for root, dirs, files in os.walk(self.path):
+                for file in files:
+                    mailbag_path = os.path.join(self.path, self.mailbag_name) + os.sep
+                    fileRoot = root + os.sep
+                    # don't count the newly-created mailbag
+                    if not fileRoot.startswith(mailbag_path):
+                        if file.lower().endswith("." + self.format_name):
+                            fileList.append(os.path.join(root, file))
+                        elif self.companion_files:
+                            companion_files.append(os.path.join(root, file))
 
         for filePath in fileList:
-
-            if self.iteration_only:
-                yield None
-                continue
-
-            originalFile = Path(format.relativePath(self.path, filePath)).as_posix()
+            rel_path = format.relativePath(self.path, filePath)
+            if len(rel_path) < 1:
+                originalFile = Path(filePath).name
+            else:
+                originalFile = Path(os.path.normpath(rel_path)).as_posix()
+            # original file is now the relative path to the MBOX from the provided path
 
             attachments = []
             errors = []
@@ -88,21 +103,31 @@ def messages(self):
                         if messagePath == ".":
                             messagePath = ""
                         unsafePath = os.path.join(os.path.dirname(originalFile), messagePath)
-                        derivativesPath = format.normalizePath(unsafePath)
+                        derivativesPath = common.normalizePath(unsafePath)
                     except Exception as e:
                         desc = "Error reading message path from headers"
                         errors = common.handle_error(errors, e, desc)
 
+                    decoded_Message_ID, errors = format.parse_header(msg["message-id"], errors)
+                    decoded_Date, errors = format.parse_header(msg["date"], errors)
+                    decoded_From, errors = format.parse_header(msg["from"], errors)
+                    decoded_To, errors = format.parse_header(msg["to"], errors)
+                    decoded_Cc, errors = format.parse_header(msg["cc"], errors)
+                    decoded_Bcc, errors = format.parse_header(msg["bcc"], errors)
+                    decoded_Subject, errors = format.parse_header(msg["subject"], errors)
+
                     message = Email(
                         Errors=errors,
-                        Message_ID=format.parse_header(msg["message-id"]),
+                        Message_ID=decoded_Message_ID,
                         Original_File=originalFile,
                         Message_Path=messagePath,
                         Derivatives_Path=derivativesPath,
-                        Date=format.parse_header(msg["date"]),
-                        From=format.parse_header(msg["from"]),
-                        To=format.parse_header(msg["to"]),
-                        Subject=format.parse_header(msg["subject"]),
+                        Date=decoded_Date,
+                        From=decoded_From,
+                        To=decoded_To,
+                        Cc=decoded_Cc,
+                        Bcc=decoded_Bcc,
+                        Subject=decoded_Subject,
                         Content_Type=msg.get_content_type(),
                         Headers=msg,
                         HTML_Body=bodies["html_body"],
@@ -119,8 +144,12 @@ def messages(self):
                 message = Email(Errors=errors)
 
             # Move EML to new mailbag directory structure
+            new_path, errors = format.moveWithDirectoryStructure(
+                self.dry_run, parent_dir, self.mailbag_name, self.format_name, filePath, errors
+            )
+            message.Errors.extend(errors)
+
             yield message
-            new_path = format.moveWithDirectoryStructure(self.dry_run, self.path, self.mailbag_name, self.format_name, filePath)
 
         if self.companion_files:
             # Move all files into mailbag directory structure
diff --git a/mailbagit/formats/example.py b/mailbagit/formats/example.py
index 5084bbf..ae45e92 100644
--- a/mailbagit/formats/example.py
+++ b/mailbagit/formats/example.py
@@ -15,12 +15,13 @@ class ExampleAccount(EmailAccount):
     def __init__(self, target_account, args, **kwargs):
         print("Parsity parse")
         # code goes here to set up mailbox and pull out any relevant account_data
-        account_data = {}
-        messages = []
+        self._account_data = {}
+        self._messages
 
+    @property
     def account_data(self):
-        return account_data
+        return self._account_data
 
     def messages(self):
-        for message in messages:
+        for message in self._messages:
             yield Email(**message)
diff --git a/mailbagit/formats/mbox.py b/mailbagit/formats/mbox.py
index b528055..5c087d8 100644
--- a/mailbagit/formats/mbox.py
+++ b/mailbagit/formats/mbox.py
@@ -1,7 +1,7 @@
 import email
 import mailbox
 
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 from pathlib import Path
 import os, shutil
 import email.errors
@@ -23,21 +23,27 @@ class Mbox(EmailAccount):
     format_agent_version = platform.python_version()
 
     def __init__(self, target_account, args, **kwargs):
-        log.debug("Parsity parse")
         # code goes here to set up mailbox and pull out any relevant account_data
-        account_data = {}
+        self._account_data = {}
 
         self.path = target_account
         self.dry_run = args.dry_run
         self.mailbag_name = args.mailbag_name
         self.companion_files = args.companion_files
-        self.iteration_only = False
-        log.info("Reading : ", Path=self.path)
+        log.info("Reading: " + self.path)
 
+    @property
     def account_data(self):
-        return account_data
+        return self._account_data
 
-    def messages(self):
+    @property
+    def number_of_messages(self):
+        count = 0
+        for _ in self.messages(iteration_only=True):
+            count += 1
+        return count
+
+    def messages(self, iteration_only=False):
 
         companion_files = []
         if os.path.isfile(self.path):
@@ -68,7 +74,7 @@ def messages(self):
             data = mailbox.mbox(filePath)
             for mail in data.itervalues():
 
-                if self.iteration_only:
+                if iteration_only:
                     yield None
                     continue
 
@@ -99,23 +105,31 @@ def messages(self):
                         messagePath = Path(format.messagePath(mailObject)).as_posix()
                         if messagePath == ".":
                             messagePath = ""
-                        derivativesPath = Path(os.path.splitext(originalFile)[0], format.normalizePath(messagePath)).as_posix()
+                        derivativesPath = Path(os.path.splitext(originalFile)[0], common.normalizePath(messagePath)).as_posix()
                     except Exception as e:
                         desc = "Error reading message path from headers"
                         errors = common.handle_error(errors, e, desc)
 
+                    decoded_Message_ID, errors = format.parse_header(mail["Message-ID"], errors)
+                    decoded_Date, errors = format.parse_header(mail["Date"], errors)
+                    decoded_From, errors = format.parse_header(mail["From"], errors)
+                    decoded_To, errors = format.parse_header(mail["To"], errors)
+                    decoded_Cc, errors = format.parse_header(mail["Cc"], errors)
+                    decoded_Bcc, errors = format.parse_header(mail["Bcc"], errors)
+                    decoded_Subject, errors = format.parse_header(mail["Subject"], errors)
+
                     message = Email(
                         Errors=errors,
-                        Message_ID=format.parse_header(mail["Message-ID"]),
+                        Message_ID=decoded_Message_ID,
                         Original_File=originalFile,
                         Message_Path=messagePath,
                         Derivatives_Path=derivativesPath,
-                        Date=format.parse_header(mail["Date"]),
-                        From=format.parse_header(mail["From"]),
-                        To=format.parse_header(mail["To"]),
-                        Cc=format.parse_header(mail["Cc"]),
-                        Bcc=format.parse_header(mail["Bcc"]),
-                        Subject=format.parse_header(mail["Subject"]),
+                        Date=decoded_Date,
+                        From=decoded_From,
+                        To=decoded_To,
+                        Cc=decoded_Cc,
+                        Bcc=decoded_Bcc,
+                        Subject=decoded_Subject,
                         Content_Type=mailObject.get_content_type(),
                         Headers=mail,
                         HTML_Body=bodies["html_body"],
@@ -135,8 +149,11 @@ def messages(self):
             # Make sure the MBOX file is closed
             data.close()
             # Move MBOX to new mailbag directory structure
-            if not self.iteration_only:
-                new_path = format.moveWithDirectoryStructure(self.dry_run, parent_dir, self.mailbag_name, self.format_name, filePath)
+            if not iteration_only:
+                # Does not check path lengths for MBOXs because `errors` was already returned to the controller
+                new_path, errors = format.moveWithDirectoryStructure(
+                    self.dry_run, parent_dir, self.mailbag_name, self.format_name, filePath, errors
+                )
 
         if self.companion_files:
             # Move all files into mailbag directory structure
diff --git a/mailbagit/formats/msg.py b/mailbagit/formats/msg.py
index 55a688b..1134927 100644
--- a/mailbagit/formats/msg.py
+++ b/mailbagit/formats/msg.py
@@ -2,7 +2,7 @@
 import os
 from pathlib import Path
 from email import parser
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 import email.errors
 from mailbagit.email_account import EmailAccount
 from mailbagit.models import Email, Attachment
@@ -25,40 +25,61 @@ class MSG(EmailAccount):
     def __init__(self, target_account, args, **kwargs):
         log.debug("Parsity parse")
         # code goes here to set up mailbox and pull out any relevant account_data
-        account_data = {}
+        self._account_data = {}
 
         self.path = target_account
         self.dry_run = args.dry_run
         self.mailbag_name = args.mailbag_name
         self.companion_files = args.companion_files
-        self.iteration_only = False
-        log.info("Reading :", Path=self.path)
 
+        log.info("Reading: " + self.path)
+
+    @property
     def account_data(self):
-        return account_data
+        return self._account_data
+
+    @property
+    def number_of_messages(self):
+        count = 0
+        for _ in self.messages(iteration_only=True):
+            count += 1
+        return count
+
+    def messages(self, iteration_only=False):
 
-    def messages(self):
-        fileList = []
         companion_files = []
-        for root, dirs, files in os.walk(self.path):
-            for file in files:
-                mailbag_path = os.path.join(self.path, self.mailbag_name) + os.sep
-                fileRoot = root + os.sep
-                # don't count the newly-created mailbag
-                if not fileRoot.startswith(mailbag_path):
-                    if file.lower().endswith("." + self.format_name):
-                        fileList.append(os.path.join(root, file))
-                    elif self.companion_files:
-                        companion_files.append(os.path.join(root, file))
+        if os.path.isfile(self.path):
+            parent_dir = os.path.dirname(self.path)
+            fileList = [self.path]
+        else:
+            parent_dir = self.path
+            fileList = []
+            for root, dirs, files in os.walk(self.path):
+                for file in files:
+                    mailbag_path = os.path.join(self.path, self.mailbag_name) + os.sep
+                    fileRoot = root + os.sep
+                    # don't count the newly-created mailbag
+                    if not fileRoot.startswith(mailbag_path):
+                        if file.lower().endswith("." + self.format_name):
+                            fileList.append(os.path.join(root, file))
+                        elif self.companion_files:
+                            companion_files.append(os.path.join(root, file))
 
         for filePath in fileList:
             # Parse email matching the input file extension
 
-            if self.iteration_only:
+            if iteration_only:
                 yield None
                 continue
 
-            originalFile = Path(format.relativePath(self.path, filePath)).as_posix()
+            rel_path = format.relativePath(self.path, filePath)
+            if len(rel_path) < 1:
+                originalFile = Path(filePath).name
+            else:
+                originalFile = Path(os.path.normpath(rel_path)).as_posix()
+            # original file is now the relative path to the MBOX from the provided path
+
+            # originalFile = Path(format.relativePath(self.path, filePath)).as_posix()
 
             attachments = []
             errors = []
@@ -86,13 +107,13 @@ def messages(self):
                     if messagePath == ".":
                         messagePath = ""
                     unsafePath = os.path.join(os.path.dirname(originalFile), messagePath)
-                    derivativesPath = Path(format.normalizePath(unsafePath)).as_posix()
+                    derivativesPath = Path(common.normalizePath(unsafePath)).as_posix()
                 except Exception as e:
                     desc = "Error reading message path from headers"
                     errors = common.handle_error(errors, e, desc)
 
                 try:
-                    for mailAttachment in mail.attachments:
+                    for i, mailAttachment in enumerate(mail.attachments):
                         if mailAttachment.getFilename():
                             attachmentName = mailAttachment.getFilename()
                         elif mailAttachment.longFilename:
@@ -110,21 +131,39 @@ def messages(self):
                             if attachmentName.lower() == "attachments.csv":
                                 desc = "attachment " + attachmentName + " will be renamed to avoid filename conflict with mailbag spec"
                                 errors = common.handle_error(errors, None, desc, "warn")
+                                attachmentWrittenName = str(i) + os.path.splitext(attachmentName)[1]
+                            else:
+                                attachmentWrittenName = common.normalizePath(attachmentName.replace("/", "%2F"))
+                        else:
+                            attachmentWrittenName = str(i)
 
                         # Try to get the mime, guess it if this doesn't work
                         mime = None
                         try:
-                            mime = mailAttachment._ensureSet("_contentType", "__substg1.0_370e")
+                            mime = mailAttachment.mimetype
                         except Exception as e:
                             desc = "Error reading mime type, guessing it instead"
                             errors = common.handle_error(errors, e, desc, "warn")
                         if mime is None:
-                            mime = format.guessMimeType(attachmentName)
+                            if attachmentName:
+                                mime = format.guessMimeType(attachmentName)
+                            else:
+                                desc = "Mimetype not found. Setting it to 'application/octet-stream'"
+                                errors = common.handle_error(errors, None, desc, "warn")
+                                mime = "application/octet-stream"
+
+                        contentID = None
+                        try:
+                            contentID = mailAttachment.contendId
+                        except Exception as e:
+                            desc = "Error reading ContentID, creating an ID instead"
+                            errors = common.handle_error(errors, e, desc, "warn")
+                        if contentID is None:
+                            contentID = uuid.uuid4().hex
 
-                        # MSGs don't seem to have a reliable content ID so we make one since emails may have multiple attachments with the same filename
-                        contentID = uuid.uuid4().hex
                         attachment = Attachment(
                             Name=attachmentName,
+                            WrittenName=attachmentWrittenName,
                             File=mailAttachment.data,
                             MimeType=mime,
                             Content_ID=contentID,
@@ -169,7 +208,11 @@ def messages(self):
                 mail.close()
 
             # Move MSG to new mailbag directory structure
-            new_path = format.moveWithDirectoryStructure(self.dry_run, self.path, self.mailbag_name, self.format_name, filePath)
+            new_path, errors = format.moveWithDirectoryStructure(
+                self.dry_run, parent_dir, self.mailbag_name, self.format_name, filePath, errors
+            )
+            message.Errors.extend(errors)
+
             yield message
 
         if self.companion_files:
diff --git a/mailbagit/formats/pst.py b/mailbagit/formats/pst.py
index 655b37c..ff83c92 100644
--- a/mailbagit/formats/pst.py
+++ b/mailbagit/formats/pst.py
@@ -1,10 +1,9 @@
 import os
-import mailbox
+import email
 from pathlib import Path
 import chardet
 from extract_msg.constants import CODE_PAGES
-from structlog import get_logger
-from email import parser
+from mailbagit.loggerx import get_logger
 from mailbagit.email_account import EmailAccount
 from mailbagit.models import Email, Attachment
 import mailbagit.helper.format as format
@@ -36,18 +35,25 @@ class PST(EmailAccount):
         def __init__(self, target_account, args, **kwargs):
             log.debug("Parsity parse")
             # code goes here to set up mailbox and pull out any relevant account_data
-
+            self._account_data = {}
             self.path = target_account
             self.dry_run = args.dry_run
             self.mailbag_name = args.mailbag_name
             self.companion_files = args.companion_files
-            self.iteration_only = False
-            log.info("Reading :", Path=self.path)
+            log.info("Reading: " + self.path)
 
+        @property
         def account_data(self):
-            return account_data
+            return self._account_data
+
+        @property
+        def number_of_messages(self):
+            count = 0
+            for _ in self.messages(iteration_only=True):
+                count += 1
+            return count
 
-        def folders(self, folder, path, originalFile):
+        def folders(self, folder, path, originalFile, iteration_only=False):
             # recursive function that calls itself on any subfolders and
             # returns a generator of messages
             # path is the email folder path of the message, separated by "/"
@@ -55,7 +61,7 @@ def folders(self, folder, path, originalFile):
                 log.debug("Reading folder: " + folder.name)
                 for index in range(folder.number_of_sub_messages):
 
-                    if self.iteration_only:
+                    if iteration_only:
                         yield None
                         continue
                     attachments = []
@@ -64,8 +70,15 @@ def folders(self, folder, path, originalFile):
                         messageObj = folder.get_sub_message(index)
 
                         try:
-                            headerParser = parser.HeaderParser()
-                            headers = headerParser.parsestr(messageObj.transport_headers)
+                            headerParser = email.parser.HeaderParser()
+                            if messageObj.transport_headers:
+                                headers = headerParser.parsestr(messageObj.transport_headers)
+                            else:
+                                # often returns none for deleted and sent items in OSTs
+                                desc = "Unable to read headers. An empty headers object will be created."
+                                errors = common.handle_error(errors, None, desc)
+                                # just make an empty object
+                                headers = headerParser.parsestr("headers: not found")
                         except Exception as e:
                             desc = "Error parsing message body"
                             errors = common.handle_error(errors, e, desc)
@@ -95,9 +108,12 @@ def folders(self, folder, path, originalFile):
                                             value = entry.get_data_as_integer()
                                             # Use the extract_msg code page in constants.py
                                             encodings[2] = {"name": CODE_PAGES[value], "label": "PidTagMessageCodepage"}
-
-                            if messageObj.html_body:
-                                html_body, html_encoding, errors = format.safely_decode("HTML", messageObj.html_body, encodings, errors)
+                            # messageObj.html_body sometimes fails. This seems to often be the case for email in "Deleted Items"
+                            try:
+                                if messageObj.html_body:
+                                    html_body, html_encoding, errors = format.safely_decode("HTML", messageObj.html_body, encodings, errors)
+                            except:
+                                pass
                             if messageObj.plain_text_body:
                                 encodings[len(encodings.keys()) + 1] = {
                                     "name": "utf-8",
@@ -120,17 +136,18 @@ def folders(self, folder, path, originalFile):
                             messagePath = path
                             if len(messagePath) > 0:
                                 messagePath = Path(messagePath).as_posix()
-                            derivativesPath = Path(os.path.splitext(originalFile)[0], format.normalizePath(messagePath)).as_posix()
+                            derivativesPath = Path(os.path.splitext(originalFile)[0], common.normalizePath(messagePath)).as_posix()
                         except Exception as e:
                             desc = "Error reading message path"
                             errors = common.handle_error(errors, e, desc)
 
                         try:
                             total_attachment_size_bytes = 0
-                            for attachmentObj in messageObj.attachments:
+                            for i, attachmentObj in enumerate(messageObj.attachments):
                                 total_attachment_size_bytes = total_attachment_size_bytes + attachmentObj.get_size()
                                 attachment_content = attachmentObj.read_buffer(attachmentObj.get_size())
 
+                                attachmentName = None
                                 try:
                                     # attachmentName = attachmentObj.get_name()
                                     # Entries found here: https://github.com/libyal/libpff/blob/main/libpff/libpff_mapi.h#L333-L335
@@ -171,12 +188,22 @@ def folders(self, folder, path, originalFile):
                                                 + " will be renamed to avoid filename conflict with mailbag spec"
                                             )
                                             errors = common.handle_error(errors, None, desc, "warn")
+                                            attachmentWrittenName = str(i) + os.path.splitext(attachmentName)[1]
+                                        else:
+                                            attachmentWrittenName = common.normalizePath(attachmentName.replace("/", "%2F"))
+                                    else:
+                                        attachmentWrittenName = str(i)
 
                                     # Guess the mime if we can't find it
                                     if mime is None:
-                                        mime = format.guessMimeType(attachmentName)
+                                        if attachmentName:
+                                            mime = format.guessMimeType(attachmentName)
+                                        else:
+                                            desc = "Mimetype not found. Setting it to 'application/octet-stream'"
+                                            errors = common.handle_error(errors, None, desc, "warn")
+                                            mime = "application/octet-stream"
 
-                                    # MSGs don't seem to have a reliable content ID so we make one since emails may have multiple attachments with the same filename
+                                    # MSGs & PSTs don't seem to have a reliable content ID so we make one since emails may have multiple attachments with the same filename
                                     contentID = uuid.uuid4().hex
 
                                 except Exception as e:
@@ -188,6 +215,7 @@ def folders(self, folder, path, originalFile):
 
                                 attachment = Attachment(
                                     Name=attachmentName,
+                                    WrittenName=attachmentWrittenName,
                                     File=attachment_content,
                                     MimeType=mime,
                                     Content_ID=contentID,
@@ -198,18 +226,26 @@ def folders(self, folder, path, originalFile):
                             desc = "Error parsing attachments"
                             errors = common.handle_error(errors, e, desc)
 
+                        decoded_Message_ID, errors = format.parse_header(headers["Message-ID"], errors)
+                        decoded_Date, errors = format.parse_header(headers["Date"], errors)
+                        decoded_From, errors = format.parse_header(headers["From"], errors)
+                        decoded_To, errors = format.parse_header(headers["To"], errors)
+                        decoded_Cc, errors = format.parse_header(headers["Cc"], errors)
+                        decoded_Bcc, errors = format.parse_header(headers["Bcc"], errors)
+                        decoded_Subject, errors = format.parse_header(headers["Subject"], errors)
+
                         message = Email(
                             Errors=errors,
-                            Message_ID=format.parse_header(headers["Message-ID"]),
+                            Message_ID=decoded_Message_ID,
                             Original_File=originalFile,
                             Message_Path=messagePath,
                             Derivatives_Path=derivativesPath,
-                            Date=format.parse_header(headers["Date"]),
-                            From=format.parse_header(headers["From"]),
-                            To=format.parse_header(headers["To"]),
-                            Cc=format.parse_header(headers["Cc"]),
-                            Bcc=format.parse_header(headers["Bcc"]),
-                            Subject=format.parse_header(headers["Subject"]),
+                            Date=decoded_Date,
+                            From=decoded_From,
+                            To=decoded_To,
+                            Cc=decoded_Cc,
+                            Bcc=decoded_Bcc,
+                            Subject=decoded_Subject,
                             Content_Type=headers.get_content_type(),
                             Headers=headers,
                             HTML_Body=html_body,
@@ -231,19 +267,17 @@ def folders(self, folder, path, originalFile):
             if folder.number_of_sub_folders:
                 for folder_index in range(folder.number_of_sub_folders):
                     subfolder = folder.get_sub_folder(folder_index)
-                    yield from self.folders(subfolder, path + "/" + subfolder.name, originalFile)
+                    yield from self.folders(subfolder, path + "/" + subfolder.name, originalFile, iteration_only=iteration_only)
             else:
-                if not self.iteration_only:
+                if not iteration_only:
                     if not folder.number_of_sub_messages:
                         # This is an email folder that does not contain any messages.
-                        # Currently, we are only warning about empty folders pending the possibility of
-                        # a better solution described in #117
-                        desc = "Folder '" + path + "' contains no messages and will be ignored"
-                        # handle_error() won't work here as-is because the errors list is added to the Message model
-                        # errors = common.handle_error(errors, None, desc, "warn")
-                        log.warn(desc + ".")
-
-        def messages(self):
+                        # Add it to self.account_data['empty_folder_paths']
+                        if not "empty_folder_paths" in self.account_data:
+                            self.account_data["empty_folder_paths"] = []
+                        self.account_data["empty_folder_paths"].append(os.path.splitext(originalFile)[0] + "/" + path)
+
+        def messages(self, iteration_only=False):
             companion_files = []
             if os.path.isfile(self.path):
                 parent_dir = os.path.dirname(self.path)
@@ -276,27 +310,26 @@ def messages(self):
                 for folder in root.sub_folders:
                     if folder.number_of_sub_folders:
                         # call recursive function to parse email folder
-                        yield from self.folders(folder, folder.name, originalFile)
+                        yield from self.folders(folder, folder.name, originalFile, iteration_only=iteration_only)
                     else:
-                        if not self.iteration_only:
+                        if not iteration_only:
                             # This is an email folder that does not contain any messages.
-                            # Currently, we are only warning about empty folders pending the possibility of
-                            # a better solution described in #117
-                            desc = "Folder '" + folder.name + "' contains no messages and will be ignored"
-                            # handle_error() won't work here as-is because the errors list is added to the Message model
-                            # errors = common.handle_error(errors, None, desc, "warn")
-                            log.warn(desc + ".")
-
+                            # Add it to self.account_data['empty_folder_paths']
+                            if not "empty_folder_paths" in self.account_data:
+                                self.account_data["empty_folder_paths"] = []
+                            self.account_data["empty_folder_paths"].append(os.path.splitext(originalFile)[0] + "/" + folder.name)
                 pst.close()
 
                 # Move PST to new mailbag directory structure
-                if not self.iteration_only:
-                    new_path = format.moveWithDirectoryStructure(
+                if not iteration_only:
+                    new_path, errors = format.moveWithDirectoryStructure(
                         self.dry_run,
                         parent_dir,
                         self.mailbag_name,
                         self.format_name,
                         filePath,
+                        # Does not check path lengths for PSTs
+                        [],
                     )
 
             if self.companion_files:
diff --git a/mailbagit/globals.py b/mailbagit/globals.py
index 7da96fe..7441470 100644
--- a/mailbagit/globals.py
+++ b/mailbagit/globals.py
@@ -1,6 +1,6 @@
 def init():
     global log_level, style
-    log_level = "WARN"
+    log_level = "WARNING"
     style = {
         "g": ["\033[92m", "\033[0m"],  # green start and end
         "cy": ["\033[96m", "\033[0m"],
diff --git a/mailbagit/guided.py b/mailbagit/guided.py
new file mode 100644
index 0000000..dadc6c2
--- /dev/null
+++ b/mailbagit/guided.py
@@ -0,0 +1,215 @@
+import os
+import sys
+from structlog import get_logger
+
+log = get_logger()
+
+
+def allow_exit(input_string):
+    """
+    This allows a user to enter "exit" to exit the program for any prompt.
+    """
+    if input_string.lower() == "exit":
+        sys.exit()
+
+
+def yes_no(query):
+    """
+    A reusable guided prompt with validation for yes/no queries.
+    Allows users to try again for invalid input.
+    Accepts "yes", "y", "no", and "n" and is not case sensative
+
+    Parameters:
+        query (String): The question that will be displayed to the user in input()
+    Returns:
+        (Boolean)
+    """
+    input_string = ""
+    input_options = ["yes", "y", "no", "n"]
+    yes = False
+    while not input_string.lower().strip() in input_options:
+        input_string = input(query + " (" + ", ".join(input_options) + "): ")
+        allow_exit(input_string)
+        if not input_string.lower().strip() in input_options:
+            print (f"Invalid input. Must be one of: {', '.join(input_options)}")
+        elif input_string.lower().strip() == "y" or input_string.lower().strip() == "yes":
+            yes = True
+    if yes:
+        return True
+    else:
+        return False
+
+
+def in_options(query, options):
+    """
+    A reusable guided prompt with validation for queries where the
+    Allows users to try again for invalid input.
+    input must be one of a number of options
+
+    Parameters:
+        query (String): The question that will be displayed to the user in input()
+    Returns:
+        input_string (String): A string that must be inv
+    """
+    input_string = ""
+    while not input_string.lower() in options:
+        input_string = input(f"{query} ({', '.join(options)}): ")
+        allow_exit(input_string)
+        if not input_string.lower() in options:
+            print (f"Invalid input. Must be one of: {', '.join(options)}")
+    return input_string
+
+
+def prompts(input_types, derivative_types, hashes, metadata_fields):
+    """
+    A set of guided prompts with validation to guide users with minimal command line experience
+    to providing the options to create a mailbag.
+    Does not return any values, but instead replaces sys.argv with user-provided input
+    This is called by mailbagit.guided() to allow for options to be enterd via input().
+
+    Parameters:
+        input_types (List): The possible input formats
+        derivative_types (List): The possible derivatives formats
+        hashes (List): The possible custom checksums allowed by bagit-python
+        metadata_fields (List): The possible bag-info.txt metadata fields
+    """
+    print('Mailbagit packages email export formats into a "mailbag".')
+
+    # Which input format?
+    input_format = in_options("Enter the file format you wish to package", input_types)
+
+    # Path to files or directory
+    path = ""
+    pathValid = False
+    while pathValid == False:
+        path = input(f"Enter a path to a {input_format.upper()} file or a directory containing {input_format.upper()} files: ")
+        allow_exit(path)
+        if os.path.isfile(path):
+            if path.lower().endswith("." + input_format.lower()):
+                pathValid = True
+            else:
+                print (f"File {path} is not a {input_format.upper()} file.")
+        elif os.path.isdir(path):
+            pathValid = True
+        else:
+            print ("Must be a valid path to a file or directory.")
+
+    # What derivatives to create?
+    # Don't allow same derivatives as input format
+    if input_format.lower() in derivative_types:
+        derivative_types.remove(input_format.lower())
+    derivatives_formats = ["invalid"]  # Needs to start invalid because the loop ends when the values are valid.
+    while not all(item in derivative_types for item in derivatives_formats):
+        derivatives_input = input("Enter the derivatives formats to create separated by spaces (" + ", ".join(derivative_types) + "): ")
+        allow_exit(derivatives_input)
+        derivatives_formats = derivatives_input.split(" ")
+        if not all(item in derivative_types for item in derivatives_formats):
+            print (f"Invalid format(s). Must be included in: {', '.join(derivative_types)}.")
+
+    # mailbag name
+    mailbag_name = ""
+    while len(mailbag_name) < 1 or os.path.isdir(os.path.join(path, mailbag_name)):
+        mailbag_name = input("Enter a name for the mailbag: ")
+        allow_exit(mailbag_name)
+        if len(mailbag_name) < 1:
+            print ("Invalid path")
+        elif os.path.isdir(os.path.join(path, mailbag_name)):
+            print ("A directory already exists at " + os.path.join(path, mailbag_name))
+
+    # Basic setup basic args
+    input_args = [sys.argv[0], path, "-i", input_format, "-m", mailbag_name, "-d"]
+    input_args.extend(derivatives_formats)
+
+    # dry run?
+    if yes_no("Would you like to try a dry run? This is a test run that will report errors but not alter your files."):
+        input_args.append("-r")
+
+    # more options?
+    if yes_no("Would you like more options? If no, we will package the mailbag."):
+
+        # Include companion files?
+        if os.path.isdir(path):
+            if yes_no("Would you like to include companion files (such as metadata files) that are present in the provided directory?"):
+                input_args.append("-f")
+
+        # Compress?
+        input_format = in_options("Would you like to compress the mailbag?", ["no", "n", "zip", "tar", "tar.gz"])
+        if not input_format == "no" and not input_format == "n":
+            input_args.extend(["-c", input_format])
+
+        # log to file?
+        logValid = False
+        logFile = False
+        while logValid == False:
+            log = input("Would you like to log to a file? ({path/to/file.log}, no, n): ")
+            allow_exit(log)
+            if log.lower().strip() == "no" or log.lower().strip() == "n":
+                logValid = True
+            elif len(os.path.dirname(log)) == 0 or os.path.isdir(os.path.dirname(log)):
+                input_args.extend(["--log", log])
+                logValid = True
+                logFile = True
+            else:
+                print (f"{log} is not a valid path to a log file.")
+
+        # JSON to stdout?
+        """
+        This is removed since the usability costs of another option outweights the limited expected use
+        if logFile == False:
+            if yes_no("Mailbagit will log to the console (stdout). Would you like it to log in JSON?"):
+                input_args.append("--log_json")
+        """
+
+        # Custom CSS?
+        # Only ask for HTML or PDF derivatives
+        if "html" in derivatives_formats or any("pdf" in formats for formats in derivatives_formats):
+            css = ""
+            cssValid = False
+            while cssValid == False:
+                css = input("Would you like to apply custom CSS to HTML and PDF derivatives? ({path/to/file.css}, no, n): ")
+                allow_exit(css)
+                if css.lower().strip() == "no" or css.lower().strip() == "n":
+                    cssValid = True
+                elif os.path.isfile(css) and css.lower().endswith(".css"):
+                    input_args.extend(["--css", css])
+                    cssValid = True
+                else:
+                    print (f"{css} is not a path to a valid CSS file.")
+
+        # Customize checksums?
+        if yes_no("Mailbagit uses sha256 and sha512 by default. Would you like to customize the checksums used?"):
+            custom_hashes = ["invalid"]  # Needs to start invalid because the loop ends when the values are valid.
+            while not all(item in hashes for item in custom_hashes):
+                hashes_input = input("Enter the checksum algorithms to use separated by spaces (" + ", ".join(hashes) + "): ")
+                allow_exit(hashes_input)
+                custom_hashes = hashes_input.split(" ")
+                if not all(item in hashes for item in custom_hashes):
+                    print (f"Invalid checksums(s). Must be included in: ({', '.join(hashes)}).")
+            for custom_hash in custom_hashes:
+                input_args.append("--" + custom_hash)
+
+        # Custom metadata?
+        if yes_no("Do you want to add custom metadata in bag-info.txt?"):
+            print("Optional Metadata Fields:")
+            print("\t" + "\n\t".join(metadata_fields))
+            custom_metadata = {}
+            metadata_done = False
+            while metadata_done == False:
+                metadata_input = input('Enter a field and value separated by colon (:), or enter "done" when complete: ')
+                allow_exit(metadata_input)
+                if metadata_input.lower().strip() == "done":
+                    metadata_done = True
+                elif len(metadata_input) < 1 or not ":" in metadata_input:
+                    print (f'Invalid input. Must be a field and value separated by a colon. e.g. "capture-agent: Microsoft Outlook"')
+                else:
+                    custom_key, custom_value = metadata_input.split(":", 1)
+                    if not custom_key in metadata_fields:
+                        print (f"Invalid field \"{custom_key}\". Must be included in: ({', '.join(metadata_fields)}).")
+                    else:
+                        print(f'--> Adding "{custom_key.strip()}: {custom_value.strip()}" to bag-info.txt.')
+                        custom_metadata[custom_key.strip()] = custom_value.strip()
+            for field in custom_metadata.keys():
+                input_args.extend(["--" + field, custom_metadata[field]])
+
+    # Replace args with args from guided prompts
+    sys.argv = input_args
diff --git a/mailbagit/helper/common.py b/mailbagit/helper/common.py
index be8a65e..5f90eea 100644
--- a/mailbagit/helper/common.py
+++ b/mailbagit/helper/common.py
@@ -1,6 +1,10 @@
+import os
 import traceback
+import urllib.parse
+from pathlib import Path
 from mailbagit.models import Error
-from structlog import get_logger
+
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
@@ -48,3 +52,78 @@ def handle_error(errors, exception, desc, level="error"):
     errors.append(errorObj)
 
     return errors
+
+
+def normalizePath(path):
+    """
+    Is called to normalize paths on windows to make sure they are valid.
+    It uses urllib.parse.quote_plus() to URL escape invalid characters
+
+    Parameters:
+        path (str): Unsafe path/to/stuff
+    Returns:
+        out_path (str): Safe path/to/stuff
+    """
+    if os.name == "nt":
+        specials = ["<", ">", ":", '"', "/", "|", "?", "*"]
+        special_names = [
+            "CON",
+            "PRN",
+            "AUX",
+            "NUL",
+            "COM1",
+            "COM2",
+            "COM3",
+            "COM4",
+            "COM5",
+            "COM6",
+            "COM",
+            "COM8",
+            "COM9",
+            "LPT1",
+            "LPT2",
+            "LPT3",
+            "LPT4",
+            "LPT5",
+            "LPT6",
+            "LPT7",
+            "LPT8",
+            "LPT9",
+        ]
+        new_path = []
+        for name in os.path.normpath(path).split(os.sep):
+            illegal = False
+            for char in specials:
+                if char in name:
+                    illegal = True
+            if illegal:
+                new_path.append(urllib.parse.quote_plus(name))
+            elif name.upper() in special_names:
+                new_path.append("~" + name + "~")
+            else:
+                new_path.append(name)
+        out_path = Path(os.path.join(*new_path)).as_posix()
+    else:
+        out_path = path
+
+    if out_path == ".":
+        return ""
+    else:
+        return out_path
+
+
+def check_path_length(path, errors):
+    """
+    Call to warn if file or directory paths exceed windows path limits
+
+    Parameters:
+        path (Str): A path to check the length of
+        errors (List): List of Error objects defined in models.py
+    Returns:
+        errors (List): List of Error objects defined in models.py
+    """
+    if len(os.path.abspath(path)) > 260 and os.name == "nt":
+        desc = f"Windows path length exceeded writing to {path}"
+        errors = handle_error(errors, None, desc, "warn")
+
+    return errors
diff --git a/mailbagit/helper/controller.py b/mailbagit/helper/controller.py
index f303a67..170c429 100644
--- a/mailbagit/helper/controller.py
+++ b/mailbagit/helper/controller.py
@@ -6,7 +6,7 @@
 import mailbagit.helper.common as common
 import mailbagit.globals as globals
 
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
@@ -39,9 +39,11 @@ def progress(current, total, start_time, prefix="", suffix="", decimals=1, lengt
     message_type = f'[{style["cy"][0]}{prefix}{style["b"][1]}]'
     # deco_prefix = f'{style["b"][0]}{prefix}{style["b"][1]}'
     # statusBar = f'|{bar}| {percent}% [{current}MB out of {total}MB] {suffix}'
-    status = f"{percent}% [{current} / {total} messages] {remaining_time}s remaining"
+    status = f"{percent}% [Processed {current} of {total} messages] {remaining_time}s remaining"
 
-    print(f"\r{dt} {message_type} {status}", end=print_End)
+    # Originally printed timestamp and [Progress] first, which was eliminated for screen readers
+    #print(f"\r{dt} {message_type} {status}", end=print_End)
+    print(f"\r{status}", end=print_End)
 
 
 def progressMessage(msg, print_End="\r"):
@@ -85,16 +87,16 @@ def writeAttachmentsToDisk(dry_run, attachments_dir, message):
             # Need to handle filename conflicts with attachments.csv
             # The format parsers raise a warning about this
             if attachment.Name.lower() == "attachments.csv":
-                writtenName = str(i) + os.path.splitext(attachment.Name)[1]
+                writtenName = attachment.WrittenName + os.path.splitext(attachment.Name)[1]
                 desc = ""
                 errors = common.handle_error(errors, None, desc, "warn")
             else:
-                writtenName = attachment.Name
+                writtenName = attachment.WrittenName
             attachment_row = [attachment.Name, writtenName, attachment.MimeType, attachment.Content_ID]
         else:
             # If there is no filename available, just use and integer
             # The format parsers raise an error about this
-            writtenName = str(i)
+            writtenName = attachment.WrittenName
             attachment_row = ["", writtenName, attachment.MimeType, attachment.Content_ID]
         attachment_data.append(attachment_row)
 
diff --git a/mailbagit/helper/derivative.py b/mailbagit/helper/derivative.py
index 8d8cc19..46c6d99 100644
--- a/mailbagit/helper/derivative.py
+++ b/mailbagit/helper/derivative.py
@@ -6,31 +6,11 @@
 import http.server
 import socketserver
 
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
 
-def startServer(dry_run, httpdShared, port=5000):
-    class QuietHandler(http.server.SimpleHTTPRequestHandler):
-        def log_message(self, format, *args):
-            pass
-
-    log.debug("Starting Server")
-    if not dry_run:
-        Handler = http.server.SimpleHTTPRequestHandler
-        with socketserver.TCPServer(("127.0.0.1", port), QuietHandler) as httpd:
-            httpdShared.append(httpd)
-            httpd.serve_forever()
-
-
-def stopServer(dry_run, httpd):
-    log.debug("Stopping Server")
-    if not dry_run:
-        httpd.shutdown()
-        httpd.server_close()
-
-
 def deleteFile(filePath):
     if os.path.exists(filePath):
         os.remove(filePath)
diff --git a/mailbagit/helper/format.py b/mailbagit/helper/format.py
index 17a5796..dd237da 100644
--- a/mailbagit/helper/format.py
+++ b/mailbagit/helper/format.py
@@ -1,7 +1,6 @@
 import os, shutil, glob
 from pathlib import Path
 import mimetypes
-import urllib.parse
 import chardet, codecs
 from email.header import Header, decode_header, make_header
 from mailbagit.models import Attachment
@@ -9,7 +8,7 @@
 import html
 import uuid
 
-from structlog import get_logger
+from mailbagit.loggerx import get_logger
 
 log = get_logger()
 
@@ -154,6 +153,7 @@ def parse_part(part, bodies, attachments, errors):
         errors = common.handle_error(errors, e, desc)
 
     # Extract attachments
+    attachmentCount = 0
     if part.get_content_maintype() == "multipart":
         pass
     elif content_disposition is None and content_id is None:
@@ -185,14 +185,21 @@ def parse_part(part, bodies, attachments, errors):
                     if attachmentName.lower() == "attachments.csv":
                         desc = "attachment " + attachmentName + " will be renamed to avoid filename conflict with mailbag spec"
                         errors = common.handle_error(errors, None, desc, "warn")
+                        attachmentWrittenName = str(attachmentCount) + os.path.splitext(attachmentName)[1]
+                    else:
+                        attachmentWrittenName = common.normalizePath(attachmentName.replace("/", "%2F"))
+                else:
+                    attachmentWrittenName = str(attachmentCount)
 
                 attachment = Attachment(
                     Name=attachmentName,
+                    WrittenName=attachmentWrittenName,
                     File=attachmentFile,
                     MimeType=content_type,
                     Content_ID=content_id,
                 )
                 attachments.append(attachment)
+                attachmentCount += 1
         except Exception as e:
             desc = "Error parsing attachments"
             errors = common.handle_error(errors, e, desc)
@@ -200,7 +207,7 @@ def parse_part(part, bodies, attachments, errors):
     return bodies, attachments, errors
 
 
-def decode_header_part(header):
+def decode_header_part(header, errors):
     """
     Used for to decode strings according to RFC 1342.
     If the string is not encoded, just return it.
@@ -210,8 +217,10 @@ def decode_header_part(header):
 
     Parameters:
         header (email.header.Header or string or None):
+        errors (List): List of Error objects defined in models.py
     Returns:
         header_string (str): A as-best-as-we-can-do decoded string
+        errors (List): List of Error objects defined in models.py
     """
     # headerObj, encoding = decode_header(header)[0]
     header_string = []
@@ -240,10 +249,10 @@ def decode_header_part(header):
                 # Dunno what to do here so just hopefully safely decode it?
                 header_string.append(headerObj.decode(errors="replace"))
 
-    return "".join(header_string)
+    return "".join(header_string), errors
 
 
-def parse_header(header):
+def parse_header(header, errors):
     """
     Used to handle headers that have RFC 1342 encoding.
     Sometimes the whole header is encoded, while
@@ -253,8 +262,10 @@ def parse_header(header):
 
     Parameters:
         header (email.header.Header or string or None):
+        errors (List): List of Error objects defined in models.py
     Returns:
         header_string (str or None): A as-best-as-we-can-do decoded string
+        errors (List): List of Error objects defined in models.py
     """
     if header is None:
         header_string = None
@@ -262,12 +273,14 @@ def parse_header(header):
         if isinstance(header, str):
             header_list = []
             for header_part in header.split(" "):
-                header_list.append(decode_header_part(header_part))
+                decoded_part, errors = decode_header_part(header_part, errors)
+                header_list.append(decoded_part)
             header_string = " ".join(header_list)
         else:
-            header_string = html.unescape(decode_header_part(header))
+            decoded_part, errors = decode_header_part(header, errors)
+            header_string = html.unescape(decoded_part)
 
-    return header_string
+    return header_string, errors
 
 
 def messagePath(headers):
@@ -289,54 +302,6 @@ def messagePath(headers):
     return messagePath
 
 
-def normalizePath(path):
-    # this is not sufficent yet
-    if os.name == "nt":
-        specials = ["<", ">", ":", '"', "/", "|", "?", "*"]
-        special_names = [
-            "CON",
-            "PRN",
-            "AUX",
-            "NUL",
-            "COM1",
-            "COM2",
-            "COM3",
-            "COM4",
-            "COM5",
-            "COM6",
-            "COM",
-            "COM8",
-            "COM9",
-            "LPT1",
-            "LPT2",
-            "LPT3",
-            "LPT4",
-            "LPT5",
-            "LPT6",
-            "LPT7",
-            "LPT8",
-            "LPT9",
-        ]
-        new_path = []
-        for name in os.path.normpath(path).split(os.sep):
-            illegal = False
-            for char in specials:
-                if char in name:
-                    illegal = True
-            if illegal:
-                new_path.append(urllib.parse.quote_plus(name))
-            else:
-                new_path.append(name)
-        out_path = Path(os.path.join(*new_path)).as_posix()
-    else:
-        out_path = path
-
-    if out_path == ".":
-        return ""
-    else:
-        return out_path
-
-
 def moveFile(dry_run, oldPath, newPath):
     os.makedirs(os.path.dirname(newPath), exist_ok=True)
     try:
@@ -367,7 +332,7 @@ def getFileBeforeAfterPath(mainPath, mailbag_name, input, file):
     return fullPath, fullFilePath, file_new_path, relPath
 
 
-def moveWithDirectoryStructure(dry_run, mainPath, mailbag_name, input, file):
+def moveWithDirectoryStructure(dry_run, mainPath, mailbag_name, input, file, errors):
     """
     Create new mailbag directory structure while maintaining the input data's directory structure.
     Uses for both email files matching the input file extension and companion files if that option is selected
@@ -379,9 +344,11 @@ def moveWithDirectoryStructure(dry_run, mainPath, mailbag_name, input, file):
         input (String): email file format to be packaged into a mailbag
         emailFolder (String): Path of the email export file relative to mainPath
         file (String): Email file path
+        errors (List): List of Error objects defined in models.py
 
     Returns:
         file_new_path (Path): The path where the file was moved
+        errors (List): List of Error objects defined in models.py
     """
 
     fullPath, fullFilePath, file_new_path, relPath = getFileBeforeAfterPath(mainPath, mailbag_name, input, file)
@@ -390,6 +357,7 @@ def moveWithDirectoryStructure(dry_run, mainPath, mailbag_name, input, file):
     else:
         log.debug("Moving companion file: " + str(fullFilePath) + " to: " + str(file_new_path) + " SubFolder: " + str(relPath))
 
+    errors = common.check_path_length(file_new_path, errors)
     if not dry_run:
         moveFile(dry_run, fullFilePath, file_new_path)
         # clean up old directory structure
@@ -405,7 +373,7 @@ def moveWithDirectoryStructure(dry_run, mainPath, mailbag_name, input, file):
                     time.sleep(0.01)
             p = p.parent
 
-    return file_new_path
+    return file_new_path, errors
 
 
 def guessMimeType(filename):
diff --git a/mailbagit/loggerx.py b/mailbagit/loggerx.py
index cda4f4d..ccfb940 100644
--- a/mailbagit/loggerx.py
+++ b/mailbagit/loggerx.py
@@ -1,19 +1,120 @@
-import os
-import logging, structlog
+import logging, os, re, sys
+from copy import copy
+import structlog
 import mailbagit.globals as globals
+from pythonjsonlogger import jsonlogger
 
+def copy_config(config):
+    """Copy relevant information from one config to another."""
 
-def configure():
+    new_config = {}
+    new_logging = config["logging"].copy()
+    new_structlog = {k: copy(v) for k, v in config["structlog"].items()}
 
-    log = structlog.get_logger()
+    new_config.update(logging=new_logging, structlog=new_structlog)
+    return new_config
 
-    globals.log_level = "WARN"
-    try:
-        globals.log_level = os.environ.get("MAILBAGIT_LOG_LEVEL", None).upper()
-    except:
-        log.warn("MAILBAGIT_LOG_LEVEL environment variable not set or incorrect")
-    level = logging._nameToLevel[globals.log_level]
 
-    structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(level))
-    log = structlog.get_logger()
-    log.info("MAILBAGIT_LOG_LEVEL:", LOGS=globals.log_level)
+# Regex to Match down or mixed-case usage of standard logging levels
+canonical_levels = (
+    "CRITICAL",
+    "ERROR",
+    "WARNING",
+    "INFO",
+    "DEBUG",
+    "NOTSET",
+)
+level_re = re.compile(r"|".join((r"^{}$".format(level) for level in canonical_levels)), re.I)
+
+stream_handler = None
+file_handler=None
+already_configured = False
+
+
+def setup_logging(level=None, stream_json=False, filename=None):
+    """sets up both logging and structlog."""
+    global stream_handler, file_handler,  already_configured
+
+    root_logger = logging.getLogger("mailbagit")
+
+    if stream_handler:
+        root_logger.removeHandler(stream_handler)
+    if file_handler:
+        root_logger.removeHandler(file_handler)
+
+    # Default configuration is info -> stdout - the MAILBAGIT_LOG_LEVEL var can be used to override default with pre-configured values
+    from_env = os.environ.get("MAILBAGIT_LOG_LEVEL", None)
+
+    level = from_env or logging.INFO
+    if isinstance(level, str) and level_re.match(level):
+        level = getattr(logging, level.upper())
+
+    config = {
+        "logging": default_logging_conf(level=level),
+        "structlog": default_structlog_conf(),
+        "level": level,
+    }
+    formatter = logging.Formatter(
+    '%(levelname)s: %(message)s')
+
+    # Forward what's needed to put the log places
+    stream_handler = logging.StreamHandler(sys.stdout)
+    if stream_json:
+        stream_handler.setFormatter(jsonlogger.JsonFormatter())
+    else:
+        stream_handler.setFormatter(formatter)
+    root_logger.addHandler(stream_handler)
+
+    if filename:
+        file_handler = logging.FileHandler(filename, mode="a")
+        if stream_json:
+            file_handler.setFormatter(jsonlogger.JsonFormatter())
+        else:
+            file_handler.setFormatter(formatter)
+        root_logger.addHandler(file_handler)
+    root_logger.setLevel(level)
+    structlog.reset_defaults()
+    structlog.configure(**config["structlog"])
+    already_configured = True
+
+
+def get_logger(name=None):
+    if not already_configured:
+        setup_logging()
+
+    # Make sure it's under the root logger
+    if name and not name.startswith("mailbagit."):
+        name = "mailbagit." + name
+
+    return structlog.get_logger(name or "mailbagit")
+
+
+# Log format is standard across all provided defaults
+# This amounts to a log of serialized JSON events with UTC timestamps and various
+# useful information attached, which formats exceptions passed to logging methods in exc_info
+def default_structlog_conf(**overrides):
+    """Generate a default configuration for structlog"""
+    conf = {
+        "logger_factory": structlog.stdlib.LoggerFactory(),
+        "wrapper_class": structlog.stdlib.BoundLogger,
+        "cache_logger_on_first_use": True,
+        "processors": [
+            structlog.stdlib.add_logger_name,
+            structlog.stdlib.add_log_level,
+            structlog.stdlib.PositionalArgumentsFormatter(),
+            structlog.processors.StackInfoRenderer(),
+            structlog.processors.format_exc_info,
+            structlog.processors.UnicodeDecoder(),
+            structlog.processors.TimeStamper(fmt="iso", utc=True),
+            structlog.stdlib.render_to_log_kwargs
+        ],
+    }
+    conf.update(**overrides)
+    return conf
+
+
+def default_logging_conf(**overrides):
+    """Generate a default stdlib logging configuration."""
+    conf = {"level": logging.INFO}
+    conf.update(**overrides)
+    return conf
diff --git a/mailbagit/models.py b/mailbagit/models.py
index 0e0da6d..a9c3f8c 100644
--- a/mailbagit/models.py
+++ b/mailbagit/models.py
@@ -5,6 +5,7 @@
 
 class Attachment(models.Base):
     Name = fields.StringField()
+    WrittenName = fields.StringField()
     File = fields.EmbeddedField(bytes)
     MimeType = fields.StringField()
     Content_ID = fields.StringField()
diff --git a/release.md b/release.md
index 23bef6d..1a434f6 100644
--- a/release.md
+++ b/release.md
@@ -2,7 +2,7 @@
 
 1. Test develop
 2. Bump version in `setup.py` and `mailbagit/__init__.py`
-3. Update [Spec compliance version](https://github.com/UAlbanyArchives/mailbagit/blob/develop/mailbagit/controller.py#L113) if neccessary.
+3. Update [Spec compliance version](https://github.com/UAlbanyArchives/mailbagit/blob/develop/mailbagit/controller.py#L114) if neccessary.
 4. Build and push dev Docker image
 5. PR and merge to main
 6. Build and test Windows executables
diff --git a/setup.py b/setup.py
index 8cb76ce..3971abf 100755
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@
 
 setuptools.setup(
     name="mailbagit",
-    version="0.3.0",
+    version="0.4.0",
     author="Gregory Wiedeman",
     author_email="gwiedeman@albany.edu",
     description="A tool for preserving email with multiple masters.",
@@ -19,20 +19,23 @@
         "License :: OSI Approved :: MIT License",
         "Operating System :: OS Independent",
     ],
-    entry_points={"console_scripts": ["mailbagit=mailbagit:cli", "mailbagit-gui=mailbagit:gui"]},
+    entry_points={"console_scripts": ["mailbagit=mailbagit:cli", "mailbagit-gui=mailbagit:gui", "mailbagit-guided=mailbagit:guided"]},
     install_requires=[
         "bagit>=1.8.1,<2",
         "beautifulsoup4>=4.11.1,<5",
         "black>=22.1.0,<23",
         "jsonmodels>=2.2,<=2.5.0",
-        "extract_msg>=0.30.10,<1",
+        "extract_msg>=0.35.3,<1",
         "structlog>=21.1.0,<22",
+        "python-json-logger>=2.0.2,<3"
         "pyparsing>=2.1.0,<3",
         "pytest>=7.0.1,<8",
         "python-dotenv>0.19.0,<=0.19.1",
         "chardet>=4.0.0,<5",
         "warcio>=1.7.4,<2",
         "requests >=2.27.1,<3",
+        "cssutils >=2.4.2,<3",
+        "python-json-logger >=2.0.2,<3",
         # for black
         "typed-ast>=1.5.3,<2",
     ],


Test patch:
diff --git a/docs/testingdayslides/index.html b/docs/testingdayslides/index.html
index d7380af..f565363 100644
--- a/docs/testingdayslides/index.html
+++ b/docs/testingdayslides/index.html
@@ -120,18 +120,18 @@
 
 * For non-Windows users
 
-`pip install mailbagit[pst]`
+`pip install 'mailbagit[pst]'`
 </script></section><section  data-markdown><script type="text/template">
 ## Working with PSTs on Windows
 
-* Visual Studio Installer
+* [Visual Studio Installer](https://visualstudio.microsoft.com/downloads/)
 	* Community edition works
 * Need "Desktop development with C++"
 * And "VS 2019 C++ x64/x86 build tools"
 </script></section><section  data-markdown><script type="text/template">
 ## `mailbagit` command
 
-`mailbagit -h`
+`mailbagit -v`
 </script></section><section  data-markdown><script type="text/template">
 ## Installing the GUI
 
@@ -155,7 +155,7 @@
 </script></section><section  data-markdown><script type="text/template">
 ## Running Windows Executables
 
-* Download them from the [v0.2.1 release on Github](https://github.com/UAlbanyArchives/mailbagit/releases/tag/v0.2.1)
+* Download them from the [v0.3.0 release on Github](https://github.com/UAlbanyArchives/mailbagit/releases/tag/v0.3.0)
 * [Unblock executables from Defender SmartScreen](https://archives.albany.edu/mailbag/exe/#unblocking-mailbagitexe-and-mailbagit-guiexe)
 </script></section><section  data-markdown><script type="text/template">
 ## PDF dependancies
@@ -188,6 +188,7 @@
 </script></section><section  data-markdown><script type="text/template">
 ## Options
 
+* -v --version
 * -r --dry-run
 * --css path/to/styles.css
 * -c --compress zip
@@ -273,12 +274,13 @@
 ## Mailbagit error reports
 
 * external to mailbag
+* `mymailbag_errors` and `mymailbag_warnings`
 * created on -r --dry-run
 	* more errors will show without dry-run
 	* errors from subprocesses
 		* wkhtmltopdf
 		* chrome
-* errors.csv
+* `errors.csv` and `warnings.csv`
 * .txt file for each message with error and full stack trace
 </script></section><section  data-markdown><script type="text/template">
 ## What's in a mailbag?
@@ -304,6 +306,7 @@
 Mailbag-Agent: mailbagit
 Mailbag-Agent-Version: 0.2.1
 Mailbag-Source: msg
+Mailbag-Specification-Version: 0.3
 Original-Included: True
 PDF-Agent: wkhtmltopdf
 PDF-Agent-Version: wkhtmltopdf 0.12.6 (with patched qt)
@@ -434,7 +437,7 @@
 </script></section><section  data-markdown><script type="text/template">
 ### Retrospective if time
 
-[Jamboard](https://jamboard.google.com/d/1Fc64zlP4rouDzNTt8WvgXdLfc0mBPdCNvHcihyCq0bQ/viewer)
+[Jamboard](https://jamboard.google.com/d/1fbI30bezI7Ngj4mq7bXWq49cRYb5ibO5I-K8vo22L1U/viewer)
 </script></section><section  data-markdown><script type="text/template">
 ### Bonus: A big PST
 
diff --git a/docs/testingdayslides/mailbagitTestingDay.html b/docs/testingdayslides/mailbagitTestingDay.html
index d7380af..f565363 100644
--- a/docs/testingdayslides/mailbagitTestingDay.html
+++ b/docs/testingdayslides/mailbagitTestingDay.html
@@ -120,18 +120,18 @@
 
 * For non-Windows users
 
-`pip install mailbagit[pst]`
+`pip install 'mailbagit[pst]'`
 </script></section><section  data-markdown><script type="text/template">
 ## Working with PSTs on Windows
 
-* Visual Studio Installer
+* [Visual Studio Installer](https://visualstudio.microsoft.com/downloads/)
 	* Community edition works
 * Need "Desktop development with C++"
 * And "VS 2019 C++ x64/x86 build tools"
 </script></section><section  data-markdown><script type="text/template">
 ## `mailbagit` command
 
-`mailbagit -h`
+`mailbagit -v`
 </script></section><section  data-markdown><script type="text/template">
 ## Installing the GUI
 
@@ -155,7 +155,7 @@
 </script></section><section  data-markdown><script type="text/template">
 ## Running Windows Executables
 
-* Download them from the [v0.2.1 release on Github](https://github.com/UAlbanyArchives/mailbagit/releases/tag/v0.2.1)
+* Download them from the [v0.3.0 release on Github](https://github.com/UAlbanyArchives/mailbagit/releases/tag/v0.3.0)
 * [Unblock executables from Defender SmartScreen](https://archives.albany.edu/mailbag/exe/#unblocking-mailbagitexe-and-mailbagit-guiexe)
 </script></section><section  data-markdown><script type="text/template">
 ## PDF dependancies
@@ -188,6 +188,7 @@
 </script></section><section  data-markdown><script type="text/template">
 ## Options
 
+* -v --version
 * -r --dry-run
 * --css path/to/styles.css
 * -c --compress zip
@@ -273,12 +274,13 @@
 ## Mailbagit error reports
 
 * external to mailbag
+* `mymailbag_errors` and `mymailbag_warnings`
 * created on -r --dry-run
 	* more errors will show without dry-run
 	* errors from subprocesses
 		* wkhtmltopdf
 		* chrome
-* errors.csv
+* `errors.csv` and `warnings.csv`
 * .txt file for each message with error and full stack trace
 </script></section><section  data-markdown><script type="text/template">
 ## What's in a mailbag?
@@ -304,6 +306,7 @@
 Mailbag-Agent: mailbagit
 Mailbag-Agent-Version: 0.2.1
 Mailbag-Source: msg
+Mailbag-Specification-Version: 0.3
 Original-Included: True
 PDF-Agent: wkhtmltopdf
 PDF-Agent-Version: wkhtmltopdf 0.12.6 (with patched qt)
@@ -434,7 +437,7 @@
 </script></section><section  data-markdown><script type="text/template">
 ### Retrospective if time
 
-[Jamboard](https://jamboard.google.com/d/1Fc64zlP4rouDzNTt8WvgXdLfc0mBPdCNvHcihyCq0bQ/viewer)
+[Jamboard](https://jamboard.google.com/d/1fbI30bezI7Ngj4mq7bXWq49cRYb5ibO5I-K8vo22L1U/viewer)
 </script></section><section  data-markdown><script type="text/template">
 ### Bonus: A big PST