Repository: UAlbanyArchives/mailbagit (language: python)

Issue:
Make a copy of source data instead of bagging-in-place
**Is your feature request related to a problem? Please describe.**
I prefer to make copies in my workflow, e.g. step 1. transferred package, step 2. make copy, step 3. transform copy

**Describe the solution you'd like**
I would like the source file to be left in place
```
mailbagit source_data -keep ...
ls source_data
> source_data
```

**Describe alternatives you've considered**
We could make the copy outside of the tool
`cp source_data backup/ | mailbagit source_data ...`


Moving options 226 227
 ## Type of Contribution

- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] New component
- [ ] Refactoring (no functional changes)
- [ ] Documentation-only

## What does this implement/fix? Explain your changes.

Adds `-k` or `--keep` to copy files into a mailbag instead of moving them. Also allows creation of mailbag in another directory, as `-m`/`--mailbag` now accepts `/path/to/my_mailbag` and `C:\Users\Me\my_mailbag`. When full paths are used, `mailbagit` will create the mailbag in this directory.

## Link to issue?

#226 and #227

- [x] Issue closed
- [ ] 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!
- [x] This contribution has sufficient documentation
- [ ] Tests for the changes have been added
- [x] All tests pass

#### How has this been tested?
**Operating System:** Win10 andUbuntu 20.04
**Python Version:** 3.9.12

## 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/.github/workflows/build.yml b/.github/workflows/build.yml
index 5bd90f7..b7b2746 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.7, 3.8, 3.9]
+        python-version: ['3.7', '3.8', '3.9', '3.10']
 
     steps:
       - uses: actions/checkout@v2
@@ -23,6 +23,7 @@ jobs:
           sudo apt-get install libgtk-3-dev
       - name: Install Python dependencies
         run: |
+          pip install attrdict3
           pip install -U -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-22.04 wxPython
           pip install .
           pip install libpff-python==20211114
diff --git a/README.md b/README.md
index ff50896..2d5f3c7 100755
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
 
 A tool for creating and managing Mailbags, a package for preserving email in multiple formats. It contains an open [specification for mailbags](https://archives.albany.edu/mailbag/spec/), as well as the `mailbagit` and `mailbagit-gui` tools for packaging email exports into mailbags.
 
+`mailbagit` can be used to convert native email formats, such as PST, MSG, EML, and MBOX into PDF, HTML, WARC, and other formats and combines them into stable packages for preservation.
+
 ## Installation
 
 ```
@@ -15,19 +17,30 @@ You can also run `mailbagit` using a [Docker image](https://archives.albany.edu/
 
 ## Quick start
 
-Examples:
+### Examples:
+
+MSG files to PDF, EML, and WARC
 
 ```
 mailbagit path/to/messages -i msg --derivatives eml pdf warc --mailbag_name my_mailbag
 ```
 
+MBOX to PDF and plain text
+
 ```
-mailbagit path/to/inbox.mbox -i mbox -d txt pdf-chrome -m my_mailbag -r
+mailbagit path/to/mbox_dir -i mbox -d txt pdf-chrome -m my_mailbag -r
 ```
 
+PST to PDF, MBOX, EML, and WARC
+
 ```
 mailbagit path/to/export.pst -i pst -d mbox eml pdf warc -m my_mailbag
 ```
+EML to PDF and WARC in another directory
+
+```
+mailbagit path/to/messages -i eml -d pdf warc -m /path/to/my_mailbag
+```
 
 See the [documentation](https://archives.albany.edu/mailbag/use/) for more details on:
 
@@ -36,8 +49,65 @@ See the [documentation](https://archives.albany.edu/mailbag/use/) for more detai
 * [logging](https://archives.albany.edu/mailbag/logging/)
 * [plugins](https://archives.albany.edu/mailbag/plugins/)
 
+## Arguments
+
+The arguments listed below can be entered in the command line when using `mailbagit`or entered in `mailbagit-gui` fields
+
+### Mandatory Arguments
+
+* **path**:
+> A path to email to be packaged into a mailbag. This can be a single file or a directory containing a number of email exports.
+
+* **-m --mailbag**: 
+> A new directory for the mailbag, such as `/path/to/my_mailbag`, or just `my_mailbag` to use the same location as the source email. Must be a valid directory or file name and must not already exist.
+
+* **-i --input**:  
+> File format to use  as input for a mailbag.
+> Argument takes single input.
+> e.g. `-i imap` or `-i pst`
+
+* **-d --derivatives**:
+> Specifies a single or list of derivative formats that mailbagit will create and package into the mailbag.
+> Argument takes multiple inputs.
+e.g. `-d eml pdf warc`
+
+
+### Mailbagit Optional  Arguments
+
+* **-v --version**
+> Reports the version number and exits.
+
+* **-r --dry-run**
+> Performs a test run that will not alter any files other than writing an error report. When this flag is used, `mailbagit` parses all the email it is provide and formats derivatives as much as it can without writing anything to disk. If there are any error or warnings, this will create an error report with an `errors.csv` listing all issues as well as a full stack trace in a `.txt` file.
+
+* **-k --keep**
+> Keeps the source files as-is and copies instead of moving them into a mailbag.
+
+* **--css**
+> Path to a CSS file to override the included CSS when creating PDF or HTML derivatives
+> Argument takes single file path as input.
+
+* **-c --compress**
+> Compresses the mailbag as a ZIP, TAR, or TAR.GZ
+> e.g. `-c zip` or `-c tar.gz`
+
+* **-f, --companion_files**
+> Allows for companion metadata files to be packaged alongside email export files.
+> When this option is used, `mailbagit` will recursively include all the files in the directory provided into a mailbag.
+
+### Bagit-python arguments
+
+Mailbagit also accepts most [bagit-python](https://github.com/LibraryOfCongress/bagit-python) arguments. Thus, you can provide arguments like `--processes 2` or arguments to add metadata such as `--source-organization University at Albany, SUNY` 
+
+The only bag-python arguments that `mailbagit` does not support are `-log`, `-quiet`, `-validate`, `-fast`, and `-completeness_only`
+
+If you would like to validate your mailbag, `mailbagit` comes with [bagit-python](https://github.com/LibraryOfCongress/bagit-python) installed. Thus, you can run:
+
+```
+bagit.py --validate /path/to/mailbag
+```
 
-### Development setup
+## Development setup
 
 ```
 git clone git@github.com:UAlbanyArchives/mailbagit.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 199f52f..f8650fe 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 199f52f..f8650fe 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 4895a23..88f57ab 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 0ed6809..d3ad047 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 916f64a..3e9cecd 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 9f0c3d8..3bd3d8d 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/Headers.pickle b/data/msg-Digitization Archiving Solutions/1/Headers.pickle
index 2135cc2..a35d924 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/msg-Digitization Archiving Solutions/1/Message_ID.txt b/data/msg-Digitization Archiving Solutions/1/Message_ID.txt
index 6178f69..38fd1b0 100644
--- a/data/msg-Digitization Archiving Solutions/1/Message_ID.txt
+++ b/data/msg-Digitization Archiving Solutions/1/Message_ID.txt
@@ -1,1 +1,2 @@
-<MN2PR19MB407776A0698BD977B5EE2F3FB3289@MN2PR19MB4077.namprd19.prod.outlook.com>
\ No newline at end of file
+
+ <MN2PR19MB407776A0698BD977B5EE2F3FB3289@MN2PR19MB4077.namprd19.prod.outlook.com>
\ No newline at end of file
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 4738360..5eb858a 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 6358cdc..8f56a79 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/using_mailbagit/mailbagit.md b/docs/using_mailbagit/mailbagit.md
index 634a6cc..142f548 100755
--- a/docs/using_mailbagit/mailbagit.md
+++ b/docs/using_mailbagit/mailbagit.md
@@ -45,9 +45,8 @@ The arguments listed below can be entered in the command line when using `mailba
 * **path**:
 > A path to email to be packaged into a mailbag. This can be a single file or a directory containing a number of email exports.
 
-* **-m --mailbag-name**: 
-> This will be used as the directory name for the mailbag, or the filename if compression is used.
-> Must be a valid directory or file name.
+* **-m --mailbag**: 
+> A new directory for the mailbag, such as `/path/to/my_mailbag`, or just `my_mailbag` to use the same location as the source email. Must be a valid directory or file name and must not already exist.
 
 * **-i --input**:  
 > File format to use  as input for a mailbag.
@@ -62,11 +61,14 @@ e.g. `-d eml pdf warc`
 
 ### Mailbagit Optional  Arguments
 
+* **-v --version**
+> Reports the version number and exits.
+
 * **-r --dry-run**
 > Performs a test run that will not alter any files other than writing an error report. When this flag is used, `mailbagit` parses all the email it is provide and formats derivatives as much as it can without writing anything to disk. If there are any error or warnings, this will create an error report with an `errors.csv` listing all issues as well as a full stack trace in a `.txt` file.
 
-* **-v --version**
-> Reports the version number and exits.
+* **-k --keep**
+> Keeps the source files as-is and copies instead of moving them into a mailbag.
 
 * **--css**
 > Path to a CSS file to override the included CSS when creating PDF or HTML derivatives
diff --git a/dumpData.py b/dumpData.py
index 2ad6d85..719f44f 100644
--- a/dumpData.py
+++ b/dumpData.py
@@ -8,6 +8,15 @@
 args = bagit_parser.parse_args()
 parsers = ["pst", "mbox", "msg", "eml"]
 
+args.path = args.directory[0]
+args.keep = True
+if os.path.isfile:
+    source_parent_dir = os.path.dirname(args.path)
+else:
+    source_parent_dir = args.path
+mailbag_dir = "data"
+mailbag_name = "New_Mailbag"
+
 for parser in parsers:
     args.input = parser
     args.derivatives = ["html"]
@@ -17,7 +26,8 @@
 
     controller = Controller(args)
 
-    mail_account: EmailAccount = controller.format(args.directory[0], args)
+    # mail_account: EmailAccount = controller.format(args.directory[0], args)
+    mail_account: EmailAccount = controller.format(args, source_parent_dir, mailbag_dir, mailbag_name)
     count = 0
     for message in mail_account.messages():
         count += 1
diff --git a/mailbagit/__init__.py b/mailbagit/__init__.py
index e6b5d0c..086eb64 100644
--- a/mailbagit/__init__.py
+++ b/mailbagit/__init__.py
@@ -144,7 +144,13 @@
 
 
 # add mailbagit-specific required args here
-mailbagit_args.add_argument("-m", "--mailbag_name", required=True, help="A directory name for the mailbag root directory.", nargs=None)
+mailbagit_args.add_argument(
+    "-m",
+    "--mailbag",
+    required=True,
+    help="A new directory for the mailbag, such as '/path/to/my_mailbag', or just 'my_mailbag' to use the same location as the source email.",
+    nargs=None,
+)
 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
 )
@@ -180,6 +186,9 @@
 mailbagit_options.add_argument(
     "-r", "--dry-run", help="A dry run performs a trial run with no changes made.", default=False, action="store_true"
 )
+mailbagit_options.add_argument(
+    "-k", "--keep", help="Leaves source email as-is and makes a copy into a mailbag.", 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"
 )
@@ -260,7 +269,7 @@ def gui():
 
 
 def main(args):
-    if hasattr(args, 'log_json'):
+    if hasattr(args, "log_json"):
         setup_logging(stream_json=args.log_json, filename=args.log)
     else:
         setup_logging(filename=args.log)
@@ -270,6 +279,13 @@ def main(args):
         error_msg = "Invalid path, does not exist as a file or directory."
         mailbag_parser.error((error_msg))
 
+    if os.path.isdir(args.mailbag):
+        error_msg = "Invalid mailbag. Directory must not already exist."
+        mailbag_parser.error((error_msg))
+    elif os.path.isfile(args.mailbag):
+        error_msg = "Invalid mailbag. Must be a new directory that does not already exist."
+        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))
diff --git a/mailbagit/controller.py b/mailbagit/controller.py
index 9535418..46fe90b 100644
--- a/mailbagit/controller.py
+++ b/mailbagit/controller.py
@@ -93,21 +93,26 @@ def human_size(self, size, units=[" bytes", " KB", " MB", " GB", " TB", " PB", "
 
     def generate_mailbag(self):
 
-        mail_account: EmailAccount = self.format(self.args.path, self.args)
-
         # Create folder mailbag folder before writing mailbag.csv
         if os.path.isfile(self.args.path):
-            parent_dir = os.path.dirname(self.args.path)
+            source_parent_dir = os.path.dirname(self.args.path)
+        else:
+            source_parent_dir = self.args.path
+        # if mailbag_name arg is absolute path, create the mailbag there, if not, create the mailbag in the source directory
+        if os.path.isabs(self.args.mailbag):
+            mailbag_dir = self.args.mailbag
         else:
-            parent_dir = self.args.path
-        mailbag_dir = os.path.join(parent_dir, self.args.mailbag_name)
+            mailbag_dir = os.path.join(source_parent_dir, self.args.mailbag)
+        mailbag_name = os.path.basename(self.args.mailbag)
         attachments_dir = os.path.join(str(mailbag_dir), "data", "attachments")
-        error_dir = os.path.join(parent_dir, str(self.args.mailbag_name) + "_errors")
-        warn_dir = os.path.join(parent_dir, str(self.args.mailbag_name) + "_warnings")
+        error_dir = os.path.join(os.path.dirname(mailbag_dir), str(mailbag_name) + "_errors")
+        warn_dir = os.path.join(os.path.dirname(mailbag_dir), str(mailbag_name) + "_warnings")
+
+        mail_account: EmailAccount = self.format(self.args, source_parent_dir, mailbag_dir, mailbag_name)
 
         log.debug("Creating mailbag at " + str(mailbag_dir))
         if not self.args.dry_run:
-            os.mkdir(mailbag_dir)
+            os.makedirs(mailbag_dir)
             # 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"
@@ -240,7 +245,7 @@ def generate_mailbag(self):
                     f.write(warn_text)
                     f.close()
                 for d in derivatives:
-                    folder_path = common.normalizePath(os.path.join(d.format_subdirectory, empty_folder))
+                    folder_path = os.path.join(d.format_subdirectory, common.normalizePath(empty_folder))
                     if not self.args.dry_run:
                         if not os.path.isdir(folder_path):
                             log.debug("Writing empty folder " + str(folder_path))
@@ -308,7 +313,7 @@ def generate_mailbag(self):
                 # Deleting the mailbag if compressed files are present
                 shutil.rmtree(mailbag_dir)
 
-        #controller.progressMessage("", print_End="\n")
+        # controller.progressMessage("", print_End="\n")
         log.info("Finished packaging mailbag.")
 
         return mail_account.messages()
diff --git a/mailbagit/derivatives/mbox.py b/mailbagit/derivatives/mbox.py
index 1d31eeb..38df1bf 100644
--- a/mailbagit/derivatives/mbox.py
+++ b/mailbagit/derivatives/mbox.py
@@ -37,7 +37,7 @@ def do_task_per_message(self, message):
             try:
                 if len(message.Derivatives_Path) < 1:
                     out_dir = self.format_subdirectory
-                    filename = os.path.join(out_dir, self.args.mailbag_name + ".mbox")
+                    filename = os.path.join(out_dir, os.path.basename(self.args.mailbag) + ".mbox")
                 elif len(message.Derivatives_Path.strip("/").split("/")) == 1:
                     out_dir = self.format_subdirectory
                     filename = os.path.join(out_dir, message.Derivatives_Path.strip("/") + ".mbox")
diff --git a/mailbagit/email_account.py b/mailbagit/email_account.py
index 4f4a7b6..2b498b7 100644
--- a/mailbagit/email_account.py
+++ b/mailbagit/email_account.py
@@ -31,7 +31,7 @@ def __init_subclass__(cls, **kwargs):
         __class__.registry[cls.format_name] = cls
 
     @abstractmethod
-    def __init__(self, target_account, args, **kwargs):
+    def __init__(self, args, mailbag_dir, mailbag_name, **kwargs):
         """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)
diff --git a/mailbagit/formats/eml.py b/mailbagit/formats/eml.py
index d578585..37cad5b 100644
--- a/mailbagit/formats/eml.py
+++ b/mailbagit/formats/eml.py
@@ -22,14 +22,17 @@ class EML(EmailAccount):
     format_agent = email.__name__
     format_agent_version = platform.python_version()
 
-    def __init__(self, target_account, args, **kwargs):
+    def __init__(self, args, source_parent_dir, mailbag_dir, mailbag_name, **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.path = args.path
         self.dry_run = args.dry_run
-        self.mailbag_name = args.mailbag_name
+        self.keep = args.keep
+        self.mailbag_name = mailbag_name
+        self.mailbag_dir = mailbag_dir
+        self.source_parent_dir = source_parent_dir
         self.companion_files = args.companion_files
 
         log.info("Reading: " + self.path)
@@ -50,10 +53,8 @@ def number_of_messages(self):
     def messages(self):
         companion_files = []
         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:
@@ -145,7 +146,7 @@ def messages(self):
 
             # 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
+                self.dry_run, self.keep, self.source_parent_dir, self.mailbag_dir, self.mailbag_name, self.format_name, filePath, errors
             )
             message.Errors.extend(errors)
 
@@ -153,5 +154,15 @@ def messages(self):
 
         if self.companion_files:
             # Move all files into mailbag directory structure
+            log.debug("Moving compantion files...")
             for companion_file in companion_files:
-                new_path = format.moveWithDirectoryStructure(self.dry_run, self.path, self.mailbag_name, self.format_name, companion_file)
+                new_path = format.moveWithDirectoryStructure(
+                    self.dry_run,
+                    self.keep,
+                    self.source_parent_dir,
+                    self.mailbag_dir,
+                    self.mailbag_name,
+                    self.format_name,
+                    companion_file,
+                    [],
+                )
diff --git a/mailbagit/formats/example.py b/mailbagit/formats/example.py
index ae45e92..afb65d7 100644
--- a/mailbagit/formats/example.py
+++ b/mailbagit/formats/example.py
@@ -12,7 +12,7 @@ class ExampleAccount(EmailAccount):
     format_agent = ""
     format_agent_version = ""
 
-    def __init__(self, target_account, args, **kwargs):
+    def __init__(self, args, source_parent_dir, mailbag_name, **kwargs):
         print("Parsity parse")
         # code goes here to set up mailbox and pull out any relevant account_data
         self._account_data = {}
diff --git a/mailbagit/formats/mbox.py b/mailbagit/formats/mbox.py
index 5c087d8..fc8b304 100644
--- a/mailbagit/formats/mbox.py
+++ b/mailbagit/formats/mbox.py
@@ -22,13 +22,16 @@ class Mbox(EmailAccount):
     format_agent = mailbox.__name__
     format_agent_version = platform.python_version()
 
-    def __init__(self, target_account, args, **kwargs):
+    def __init__(self, args, source_parent_dir, mailbag_dir, mailbag_name, **kwargs):
         # code goes here to set up mailbox and pull out any relevant account_data
         self._account_data = {}
 
-        self.path = target_account
+        self.path = args.path
         self.dry_run = args.dry_run
-        self.mailbag_name = args.mailbag_name
+        self.keep = args.keep
+        self.mailbag_name = mailbag_name
+        self.mailbag_dir = mailbag_dir
+        self.source_parent_dir = source_parent_dir
         self.companion_files = args.companion_files
         log.info("Reading: " + self.path)
 
@@ -47,10 +50,8 @@ def messages(self, iteration_only=False):
 
         companion_files = []
         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:
@@ -152,10 +153,20 @@ def messages(self, iteration_only=False):
             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
+                    self.dry_run, self.keep, self.source_parent_dir, self.mailbag_dir, self.mailbag_name, self.format_name, filePath, errors
                 )
 
         if self.companion_files:
             # Move all files into mailbag directory structure
+            log.debug("Moving compantion files...")
             for companion_file in companion_files:
-                new_path = format.moveWithDirectoryStructure(self.dry_run, self.path, self.mailbag_name, self.format_name, companion_file)
+                new_path = format.moveWithDirectoryStructure(
+                    self.dry_run,
+                    self.keep,
+                    self.source_parent_dir,
+                    self.mailbag_dir,
+                    self.mailbag_name,
+                    self.format_name,
+                    companion_file,
+                    [],
+                )
diff --git a/mailbagit/formats/msg.py b/mailbagit/formats/msg.py
index 1134927..ac4448d 100644
--- a/mailbagit/formats/msg.py
+++ b/mailbagit/formats/msg.py
@@ -22,14 +22,17 @@ class MSG(EmailAccount):
     format_agent = extract_msg.__name__
     format_agent_version = extract_msg.__version__
 
-    def __init__(self, target_account, args, **kwargs):
+    def __init__(self, args, source_parent_dir, mailbag_dir, mailbag_name, **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.path = args.path
         self.dry_run = args.dry_run
-        self.mailbag_name = args.mailbag_name
+        self.keep = args.keep
+        self.mailbag_name = mailbag_name
+        self.mailbag_dir = mailbag_dir
+        self.source_parent_dir = source_parent_dir
         self.companion_files = args.companion_files
 
         log.info("Reading: " + self.path)
@@ -49,10 +52,8 @@ def messages(self, iteration_only=False):
 
         companion_files = []
         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:
@@ -176,7 +177,7 @@ def messages(self, iteration_only=False):
 
                 message = Email(
                     Errors=errors,
-                    Message_ID=mail.messageId.strip(),
+                    Message_ID=mail.messageId,
                     Original_File=originalFile,
                     Message_Path=messagePath,
                     Derivatives_Path=derivativesPath,
@@ -209,7 +210,7 @@ def messages(self, iteration_only=False):
 
             # Move MSG to new mailbag directory structure
             new_path, errors = format.moveWithDirectoryStructure(
-                self.dry_run, parent_dir, self.mailbag_name, self.format_name, filePath, errors
+                self.dry_run, self.keep, self.source_parent_dir, self.mailbag_dir, self.mailbag_name, self.format_name, filePath, errors
             )
             message.Errors.extend(errors)
 
@@ -217,5 +218,15 @@ def messages(self, iteration_only=False):
 
         if self.companion_files:
             # Move all files into mailbag directory structure
+            log.debug("Moving compantion files...")
             for companion_file in companion_files:
-                new_path = format.moveWithDirectoryStructure(self.dry_run, self.path, self.mailbag_name, self.format_name, companion_file)
+                new_path = format.moveWithDirectoryStructure(
+                    self.dry_run,
+                    self.keep,
+                    self.source_parent_dir,
+                    self.mailbag_dir,
+                    self.mailbag_name,
+                    self.format_name,
+                    companion_file,
+                    [],
+                )
diff --git a/mailbagit/formats/pst.py b/mailbagit/formats/pst.py
index ff83c92..c5d23b8 100644
--- a/mailbagit/formats/pst.py
+++ b/mailbagit/formats/pst.py
@@ -32,13 +32,16 @@ class PST(EmailAccount):
         format_agent = pypff.__name__
         format_agent_version = metadata.version("libpff-python")
 
-        def __init__(self, target_account, args, **kwargs):
+        def __init__(self, args, source_parent_dir, mailbag_dir, mailbag_name, **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.path = args.path
             self.dry_run = args.dry_run
-            self.mailbag_name = args.mailbag_name
+            self.keep = args.keep
+            self.mailbag_name = mailbag_name
+            self.mailbag_dir = mailbag_dir
+            self.source_parent_dir = source_parent_dir
             self.companion_files = args.companion_files
             log.info("Reading: " + self.path)
 
@@ -53,7 +56,7 @@ def number_of_messages(self):
                 count += 1
             return count
 
-        def folders(self, folder, path, originalFile, iteration_only=False):
+        def folders(self, folder, path, originalFile, errors, 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 "/"
@@ -65,7 +68,6 @@ def folders(self, folder, path, originalFile, iteration_only=False):
                         yield None
                         continue
                     attachments = []
-                    errors = []
                     try:
                         messageObj = folder.get_sub_message(index)
 
@@ -267,7 +269,7 @@ def folders(self, folder, path, originalFile, iteration_only=False):
             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, iteration_only=iteration_only)
+                    yield from self.folders(subfolder, path + "/" + subfolder.name, originalFile, errors, iteration_only=iteration_only)
             else:
                 if not iteration_only:
                     if not folder.number_of_sub_messages:
@@ -280,10 +282,8 @@ def folders(self, folder, path, originalFile, iteration_only=False):
         def messages(self, iteration_only=False):
             companion_files = []
             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:
@@ -307,10 +307,11 @@ def messages(self, iteration_only=False):
                 pst = pypff.file()
                 pst.open(filePath)
                 root = pst.get_root_folder()
+                errors = []
                 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, iteration_only=iteration_only)
+                        yield from self.folders(folder, folder.name, originalFile, errors, iteration_only=iteration_only)
                     else:
                         if not iteration_only:
                             # This is an email folder that does not contain any messages.
@@ -324,17 +325,27 @@ def messages(self, iteration_only=False):
                 if not iteration_only:
                     new_path, errors = format.moveWithDirectoryStructure(
                         self.dry_run,
-                        parent_dir,
+                        self.keep,
+                        self.source_parent_dir,
+                        self.mailbag_dir,
                         self.mailbag_name,
                         self.format_name,
                         filePath,
                         # Does not check path lengths for PSTs
-                        [],
+                        errors,
                     )
 
             if self.companion_files:
                 # Move all files into mailbag directory structure
+                log.debug("Moving compantion files...")
                 for companion_file in companion_files:
                     new_path = format.moveWithDirectoryStructure(
-                        self.dry_run, self.path, self.mailbag_name, self.format_name, companion_file
+                        self.dry_run,
+                        self.keep,
+                        self.source_parent_dir,
+                        self.mailbag_dir,
+                        self.mailbag_name,
+                        self.format_name,
+                        companion_file,
+                        [],
                     )
diff --git a/mailbagit/guided.py b/mailbagit/guided.py
index dadc6c2..3e90a5d 100644
--- a/mailbagit/guided.py
+++ b/mailbagit/guided.py
@@ -31,7 +31,7 @@ def yes_no(query):
         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)}")
+            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:
@@ -56,7 +56,7 @@ def in_options(query, 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)}")
+            print(f"Invalid input. Must be one of: {', '.join(options)}")
     return input_string
 
 
@@ -88,11 +88,11 @@ def prompts(input_types, derivative_types, hashes, metadata_fields):
             if path.lower().endswith("." + input_format.lower()):
                 pathValid = True
             else:
-                print (f"File {path} is not a {input_format.upper()} file.")
+                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.")
+            print("Must be a valid path to a file or directory.")
 
     # What derivatives to create?
     # Don't allow same derivatives as input format
@@ -104,17 +104,17 @@ def prompts(input_types, derivative_types, hashes, metadata_fields):
         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)}.")
+            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: ")
+        mailbag_name = input("Enter a name or output path for the mailbag: ")
         allow_exit(mailbag_name)
         if len(mailbag_name) < 1:
-            print ("Invalid path")
+            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))
+            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"]
@@ -123,6 +123,10 @@ def prompts(input_types, derivative_types, hashes, metadata_fields):
     # 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")
+    else:
+        # keep?
+        if yes_no("Would you like to keep the source file(s) as-is and copy them into the mailbag instead of moving them?"):
+            input_args.append("-k")
 
     # more options?
     if yes_no("Would you like more options? If no, we will package the mailbag."):
@@ -150,7 +154,7 @@ def prompts(input_types, derivative_types, hashes, metadata_fields):
                 logValid = True
                 logFile = True
             else:
-                print (f"{log} is not a valid path to a log file.")
+                print(f"{log} is not a valid path to a log file.")
 
         # JSON to stdout?
         """
@@ -174,7 +178,7 @@ def prompts(input_types, derivative_types, hashes, metadata_fields):
                     input_args.extend(["--css", css])
                     cssValid = True
                 else:
-                    print (f"{css} is not a path to a valid CSS file.")
+                    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?"):
@@ -184,7 +188,7 @@ def prompts(input_types, derivative_types, hashes, metadata_fields):
                 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)}).")
+                    print(f"Invalid checksums(s). Must be included in: ({', '.join(hashes)}).")
             for custom_hash in custom_hashes:
                 input_args.append("--" + custom_hash)
 
@@ -200,11 +204,11 @@ def prompts(input_types, derivative_types, hashes, metadata_fields):
                 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"')
+                    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)}).")
+                        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()
diff --git a/mailbagit/helper/format.py b/mailbagit/helper/format.py
index dd237da..ecfe8a0 100644
--- a/mailbagit/helper/format.py
+++ b/mailbagit/helper/format.py
@@ -302,67 +302,89 @@ def messagePath(headers):
     return messagePath
 
 
-def moveFile(dry_run, oldPath, newPath):
+def moveFile(dry_run, keep, oldPath, newPath):
     os.makedirs(os.path.dirname(newPath), exist_ok=True)
     try:
         log.debug("from: " + str(oldPath))
         log.debug("to: " + str(newPath))
-        shutil.move(oldPath, newPath)
+        if keep:
+            shutil.copy2(oldPath, newPath)
+        else:
+            shutil.move(oldPath, newPath)
     except IOError as e:
         log.error("Unable to move file. %s" % e)
 
 
-def getFileBeforeAfterPath(mainPath, mailbag_name, input, file):
+def getFileBeforeAfterPath(source_parent_dir, mailbag_dir, mailbag_name, input, file):
     """
-    Creates file paths for input mail files and new paths
+    Creates file paths for input mail files and new paths. This is really for moving hierarchies of
+    EML or MSG files, as the we need to recreate the directory structure relative to source_parent_dir
+    in the mailbag. However, this is called for moving (copy with --keep) all source files into the
+    mailbag, including single PST or MBOX files and companion files, which also may have relative
+    subdirectories that need to be recreated.
 
     Parameters:
-        mainPath (String) :
-        mailbag_name (String) :
-        input (String) :
-        file (String) :
+        source_parent_dir (String): Parent directory of the source files
+        mailbag_dir (String): Path where the mailbag will be written
+        mailbag_name (String): Mailbag name
+        input (String): Email file format to be packaged into a mailbag
+        file (String): Email file path
+
+    Returns:
+        full_source_parent_path (Path): Absolute path for source_parent_dir
+        full_file_path (Path): Absolute path for file that is being moved
+        file_new_path (str): Output path where the file will be moved
+        relative_path (Path): Path for file relative to source directory. Used for logging/debugging.
     """
-    fullPath = Path(mainPath).resolve()
-    fullFilePath = Path(file).resolve()
-    relPath = fullFilePath.relative_to(fullPath).parents[0]
-    filename = fullFilePath.name
-    folder_new = os.path.join(fullPath, mailbag_name, "data", input)
-    file_new_path = os.path.join(folder_new, relPath, filename)
+    full_source_parent_path = Path(source_parent_dir).resolve()
+    full_file_path = Path(file).resolve()
 
-    return fullPath, fullFilePath, file_new_path, relPath
+    relative_path = full_file_path.relative_to(full_source_parent_path).parents[0]
+    filename = full_file_path.name
 
+    folder_new = os.path.join(mailbag_dir, "data", input)
+    file_new_path = os.path.join(folder_new, relative_path, filename)
 
-def moveWithDirectoryStructure(dry_run, mainPath, mailbag_name, input, file, errors):
+    return full_source_parent_path, full_file_path, file_new_path, relative_path
+
+
+def moveWithDirectoryStructure(dry_run, keep, source_parent_dir, mailbag_dir, 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
 
     Parameters:
-        dry_run (Boolean):
-        mainPath (String): Parent or provided directory path
+        dry_run (Boolean): option to perform a test creation of a mailbag
+        keep (Boolean): option to preserve source data
+        source_parent_dir (String): Parent directory of the source files
+        mailbag_dir (String): Path where the mailbag will be written
         mailbag_name (String): Mailbag name
-        input (String): email file format to be packaged into a mailbag
-        emailFolder (String): Path of the email export file relative to mainPath
+        input (String): Email file format to be packaged into a mailbag
         file (String): Email file path
-        errors (List): List of Error objects defined in models.py
+        errors (List): List of Error objects for the message defined in models.py. moveWithDirectoryStructure is also called for moving companion files which do not have error objects since they are not messages. In that case errors should be an empty list ([]).
 
     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)
+    full_source_parent_path, full_file_path, file_new_path, relative_path = getFileBeforeAfterPath(
+        source_parent_dir, mailbag_dir, mailbag_name, input, file
+    )
+    if keep:
+        verb = "Copying"
+    else:
+        verb = "Moving"
     if file.lower().endswith("." + input.lower()):
-        log.debug("Moving: " + str(fullFilePath) + " to: " + str(file_new_path) + " SubFolder: " + str(relPath))
+        log.debug(f"{verb}: {str(full_file_path)} to: {str(file_new_path)} SubFolder: {str(relative_path)}")
     else:
-        log.debug("Moving companion file: " + str(fullFilePath) + " to: " + str(file_new_path) + " SubFolder: " + str(relPath))
+        log.debug(f"{verb} companion file: {str(full_file_path)} to: {str(file_new_path)} SubFolder: {str(relative_path)}")
 
     errors = common.check_path_length(file_new_path, errors)
     if not dry_run:
-        moveFile(dry_run, fullFilePath, file_new_path)
+        moveFile(dry_run, keep, full_file_path, file_new_path)
         # clean up old directory structure
-        p = fullFilePath.parents[0]
-        while p != p.root and p != fullPath:
+        p = full_file_path.parents[0]
+        while p != p.root and p != full_source_parent_path:
             if not os.listdir(p):
                 log.debug("Cleaning: " + str(p))
                 os.rmdir(p)
diff --git a/setup.py b/setup.py
index 31f2f94..18439a5 100755
--- a/setup.py
+++ b/setup.py
@@ -28,7 +28,7 @@
         "extract_msg>=0.34.3,<1",
         "structlog>=21.1.0,<22",
         "packaging>=21.0,<21.3",
-        "python-json-logger>=2.0.2,<3"
+        "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",


Test patch:
diff --git a/tests/test_formats.py b/tests/test_formats.py
index ee87b2b..b853f85 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -10,12 +10,31 @@
 # This is a mock object representing the args returned from argparse/Gooey
 @pytest.fixture
 def cli_args():
-    return Namespace(dry_run=True, mailbag_name="New_Mailbag", companion_files=False)
+    return Namespace(dry_run=True, path=os.path.join("data"), mailbag="New_Mailbag", keep=False, companion_files=False)
+
+
+def setup_paths(cli_args):
+
+    # Create folder mailbag folder before writing mailbag.csv
+    if os.path.isfile(cli_args.path):
+        source_parent_dir = os.path.dirname(cli_args.path)
+    else:
+        source_parent_dir = cli_args.path
+    # if mailbag_name arg is absolute path, create the mailbag there, if not, create the mailbag in the source directory
+    if os.path.isabs(cli_args.mailbag):
+        mailbag_dir = cli_args.mailbag
+    else:
+        mailbag_dir = os.path.join(source_parent_dir, cli_args.mailbag)
+    mailbag_name = os.path.basename(cli_args.mailbag)
+
+    return source_parent_dir, mailbag_dir, mailbag_name
 
 
 def test_Mbox(cli_args):
     testfile = "sample1.mbox"
-    data = EmailAccount.registry["mbox"](os.path.join("data"), cli_args).messages()
+
+    source_parent_dir, mailbag_dir, mailbag_name = setup_paths(cli_args)
+    data = EmailAccount.registry["mbox"](cli_args, source_parent_dir, mailbag_dir, mailbag_name).messages()
     dump_dir = os.path.join("data", os.path.splitext(testfile)[1][1:] + "-" + os.path.splitext(testfile)[0])
 
     for i, message in enumerate(data):
@@ -45,12 +64,14 @@ def test_Mbox(cli_args):
                                     assert dump == compare
                     assert match == True
             else:
-                assert getattr(message, field[0]) == getattr(expected, field[0])
+                assert str(getattr(message, field[0])).strip() == str(getattr(expected, field[0])).strip()
 
 
 def test_MSG(cli_args):
     testfile = "Digitization Archiving Solutions.msg"
-    data = EmailAccount.registry["msg"](os.path.join("data"), cli_args).messages()
+
+    source_parent_dir, mailbag_dir, mailbag_name = setup_paths(cli_args)
+    data = EmailAccount.registry["msg"](cli_args, source_parent_dir, mailbag_dir, mailbag_name).messages()
     dump_dir = os.path.join("data", os.path.splitext(testfile)[1][1:] + "-" + os.path.splitext(testfile)[0])
 
     for i, message in enumerate(data):
@@ -80,13 +101,14 @@ def test_MSG(cli_args):
                                     assert dump == compare
                     assert match == True
             else:
-                assert getattr(message, field[0]) == getattr(expected, field[0])
+                assert str(getattr(message, field[0])).strip() == str(getattr(expected, field[0])).strip()
 
 
 def test_EML(cli_args):
-
     testfile = "2016-06-23_144430_6e449c77fe.eml"
-    data = EmailAccount.registry["eml"](os.path.join("data"), cli_args).messages()
+
+    source_parent_dir, mailbag_dir, mailbag_name = setup_paths(cli_args)
+    data = EmailAccount.registry["eml"](cli_args, source_parent_dir, mailbag_dir, mailbag_name).messages()
     dump_dir = os.path.join("data", os.path.splitext(testfile)[1][1:] + "-" + os.path.splitext(testfile)[0])
 
     for i, message in enumerate(data):
@@ -116,7 +138,7 @@ def test_EML(cli_args):
                                     assert dump == compare
                     assert match == True
             else:
-                assert getattr(message, field[0]) == getattr(expected, field[0])
+                assert str(getattr(message, field[0])).strip() == str(getattr(expected, field[0])).strip()
 
 
 def test_PST(cli_args):
@@ -124,7 +146,9 @@ def test_PST(cli_args):
         raise pytest.skip("PST not installed, cannot test")
 
     testfile = "outlook2019_MSO_16.0.10377.20023_64-bit.pst"
-    data = EmailAccount.registry["pst"](os.path.join("data"), cli_args).messages()
+
+    source_parent_dir, mailbag_dir, mailbag_name = setup_paths(cli_args)
+    data = EmailAccount.registry["pst"](cli_args, source_parent_dir, mailbag_dir, mailbag_name).messages()
     dump_dir = os.path.join("data", os.path.splitext(testfile)[1][1:] + "-" + os.path.splitext(testfile)[0])
 
     for i, message in enumerate(data):
@@ -154,4 +178,4 @@ def test_PST(cli_args):
                                     assert dump == compare
                     assert match == True
             else:
-                assert getattr(message, field[0]) == getattr(expected, field[0])
+                assert str(getattr(message, field[0])).strip() == str(getattr(expected, field[0])).strip()