mail_deduplicate package¶
Expose package-wide elements.
Submodules¶
mail_deduplicate.action module¶
Actions performed once the selection is settled: copy, move, delete or hardlink.
Each action ID pairs an operation verb with the subset of mails it applies to, and
Action.perform() routes one to the other.
- mail_deduplicate.action.dry_run_prefix(dedup)[source]¶
Marks a summary as describing what a run would have done.
A dry run reports through the same trail as a real one, and this prefix does for the trail’s closing summary what
outcome()does for each of its lines.- Return type:
- mail_deduplicate.action.outcome(dedup, done)[source]¶
Words what happened to a mail, for its line of the trail.
A dry run touches no mail, so it says what the action would have done to each of them, where a real run reports it as done.
- Return type:
- mail_deduplicate.action.copy_mails(dedup, mails)[source]¶
Copy provided
mailsto a brand new box or an existing one.- Return type:
- mail_deduplicate.action.move_mails(dedup, mails)[source]¶
Move provided
mailsto a brand new box or an existing one.- Return type:
- mail_deduplicate.action.delete_mails(dedup, mails)[source]¶
Remove provided
mailsin-place, from their original boxes.- Return type:
- mail_deduplicate.action.LINK_TEMP_PREFIX = '.mdedup-hardlink-'¶
Prefix of the temporary link a mail is replaced through.
Every folder-based format skips dot-prefixed files when listing its mails, so a temporary left behind by an interrupted run is never read back as one.
- mail_deduplicate.action.has_own_file(mail)[source]¶
Whether the mail is backed by a file holding it alone.
File-based boxes pack all their mails into the box’s single file, which is what
pathreturns for each of them: there would be nothing to link but the whole box.- Return type:
- mail_deduplicate.action.hardlink_blocker(dedup, mail, target)[source]¶
Explain why a mail cannot be replaced by a hardlink to its target.
Returns the reason as a sentence ready to be logged, or
Nonewhen the link can go ahead. Nothing is touched along the way, so a dry run reaches the same verdicts a real run acts on.
- mail_deduplicate.action.replace_by_hardlink(mail_path, target_path)[source]¶
Point a mail’s own path at the file backing another mail.
The link is created under a temporary name in the mail’s own directory, then renamed over it: the rename is atomic and shares the mail’s filesystem by construction, so the mail is never missing from its box, whatever interrupts the run. The mail also keeps its file name, which is where
maildirrecords the per-folder flags that make the same mail read in one folder and unread in another.- Return type:
- mail_deduplicate.action.hardlink_mails(dedup, mails)[source]¶
Replace provided
mailsin-place by a hardlink to the copy kept in their set.The mails stay right where they are, under their own name: only the content they are backed by is shared with the copy that survived the selection, so the disk space they took is reclaimed.
- Return type:
- mail_deduplicate.action.OPERATIONS: dict[str, Callable[[Deduplicate, Collection[DedupMailMixin]], None]] = {'copy': <function copy_mails>, 'delete': <function delete_mails>, 'hardlink': <function hardlink_mails>, 'move': <function move_mails>}¶
The operation functions above, keyed by the verb half of an action ID.
All share the same signature: the deduplication they report to, and the mails they apply to.
- class mail_deduplicate.action.Action(*values)[source]¶
Bases:
StrEnumDefine all available action IDs.
An action ID joins an operation verb to the subset of mails it applies to: the
*-selectedactions act on the mails kept by the selection, the*-discardedones on the mails it discarded.The mails that have no duplicate belong to the selection, and so do the mails with too few headers to be hashed: the copy and move actions carry them into the box they build.
spares_uniqueexplains why the delete one leaves them behind.- COPY_SELECTED = 'copy-selected'¶
- COPY_DISCARDED = 'copy-discarded'¶
- MOVE_SELECTED = 'move-selected'¶
- MOVE_DISCARDED = 'move-discarded'¶
- DELETE_SELECTED = 'delete-selected'¶
- DELETE_DISCARDED = 'delete-discarded'¶
- HARDLINK_DISCARDED = 'hardlink-discarded'¶
- property acts_on_discarded: bool¶
Whether the action applies to the discarded mails, not the selected ones.
- property spares_unique: bool¶
Whether the action must leave the mails that have no duplicate alone.
A mail alone in its duplicate set is kept, so it belongs to the selection every
*-selectedaction targets. So does a mail with too few headers to be hashed, which joins no set at all. Copying and moving it is what makes the exported box hold the whole deduplicated corpus rather than its duplicates only, and neither loses it. Deleting it does: no strategy ever ruled on that mail, nothing was written anywhere else, and there is no other copy of it to fall back on. So the deletion applies to the mails a strategy really picked. See: https://github.com/kdeldycke/mail-deduplicate/issues/1053
mail_deduplicate.bug_report module¶
Bug reports for the errors mdedup has no rule for.
Such an error is a bug in mdedup, or a mail shape it does not handle yet. To fix it, a maintainer needs two facts: the code that raised the error, and the shape of the mail that triggered it. This module collects both, and renders them as a block that a user can paste into an issue.
Caution
A report never holds mail content. Mails carry the personal data of their senders
and recipients, and issues are public. So fingerprint() describes a mail by its
structure alone. The traceback keeps module names and line numbers, and drops the
file paths, which hold the name of the user on most systems.
Todo
Contribute the crash handler, the environment and options lines and the report invitation to click-extra, so every Click Extra CLI can invite bug reports. mdedup then keeps only the mail fingerprint and the quarantine of the failing mails.
- mail_deduplicate.bug_report.ISSUES_URL = 'https://github.com/kdeldycke/mail-deduplicate/issues'¶
Where users file bug reports.
Repeats the
IssuesURL ofpyproject.toml, and a test keeps the two in sync: a binary built by Nuitka ships no package metadata to read it from.
- mail_deduplicate.bug_report.REPORT_FORM = 'bug-report.yml'¶
File name of the issue form a report links to, in
.github/ISSUE_TEMPLATE/.
- mail_deduplicate.bug_report.REPORT_START = '----- 8< ----- mdedup bug report ----- 8< -----'¶
First line of the block a user pastes into an issue.
- mail_deduplicate.bug_report.REPORT_END = '----- 8< ----- end of bug report ----- 8< -----'¶
Last line of the block a user pastes into an issue.
- mail_deduplicate.bug_report.REPORTS_FOLDER = 'reports'¶
Folder of the cache directory where
save_report()writes each bug report.
- mail_deduplicate.bug_report.FAILURE_ATTRIBUTE = 'mdedup_failure'¶
Attribute of an exception that holds the
Failureattached byblame().
- mail_deduplicate.bug_report.FINGERPRINTED_MAILS = 2¶
Mails of one failure that get a fingerprint.
A failing duplicate set does not tell which of its mails caused the error, and its first two mails usually include the culprit.
- mail_deduplicate.bug_report.MAX_FINGERPRINT_LINES = 60¶
Cap on the lines of a fingerprint, for mails with hundreds of headers or parts.
- mail_deduplicate.bug_report.MAX_FRAMES = 12¶
Innermost frames kept from a traceback. A
RecursionErrorhas a thousand.
- mail_deduplicate.bug_report.MAX_KINDS = 5¶
Kinds of failure detailed in a report. The warnings of the run name the others.
- mail_deduplicate.bug_report.MAX_VALUE_LENGTH = 200¶
Cap on the characters of an error message or of a header value shown verbatim.
- mail_deduplicate.bug_report.PARAMETER_HEADERS = frozenset({'content-disposition', 'content-type'})¶
Headers a fingerprint shows with their structure only: see
structure().
- mail_deduplicate.bug_report.SAFE_PARAMETERS = frozenset({'charset', 'delsp', 'format', 'micalg', 'protocol', 'report-type', 'type'})¶
Parameters of
PARAMETER_HEADERSshown as they are: they tell how to read a part.structure()names theSHAPED_PARAMETERSwithout their value, and hides the name of any other parameter too.
- mail_deduplicate.bug_report.SAMPLES = 3¶
Failures of each kind that a report details, with the fingerprint of their mails.
More samples of the same bug add length to a report, not information, and each fingerprint costs one more read and parse of its mail.
- mail_deduplicate.bug_report.SHAPED_PARAMETERS = frozenset({'boundary', 'filename', 'name'})¶
Parameters of
PARAMETER_HEADERSa fingerprint names, with their shape alone.They can hold content: a file name, or a boundary built from a sender address.
- mail_deduplicate.bug_report.VERBATIM_HEADERS = frozenset({'content-transfer-encoding', 'date', 'mime-version'})¶
Headers a fingerprint shows as they are: they hold structure, not content.
Their comments are free text, so
mask_comments()shows each one by its shape.Datecaused several of the past crashes, by its parsing or by the bytes of its comment. See: https://github.com/kdeldycke/mail-deduplicate/issues/600, https://github.com/kdeldycke/mail-deduplicate/issues/954 and https://github.com/kdeldycke/mail-deduplicate/issues/1117
- mail_deduplicate.bug_report.CONTROL_BYTES = re.compile(b'[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]')¶
C0 control bytes and
DEL, except the tab and the line breaks of a mail.
- mail_deduplicate.bug_report.ENCODED_WORD = re.compile('=\\?([^?\\s]+)\\?([bBqQ])\\?')¶
Start of an RFC 2047 encoded word: its charset and its encoding.
- mail_deduplicate.bug_report.TOKEN = re.compile("[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:/[!#$%&'*+.^_`|~0-9A-Za-z-]+)?")¶
An RFC 2045 token, or a
type/subtypepair of them.
- mail_deduplicate.bug_report.plural(count, singular, several=None)[source]¶
Count things in words, or say nothing about a count of zero.
- Return type:
- mail_deduplicate.bug_report.to_bytes(text)[source]¶
Recover the bytes a parsed value came from.
Parsing a mail from bytes keeps each non-ASCII byte as a lone surrogate, which
surrogateescapeturns back into that byte. Other surrogates come from no byte, sosurrogatepasswrites them as the invalid UTF-8 they are.- Return type:
- mail_deduplicate.bug_report.byte_class(value)[source]¶
Describe a value by its length and the kind of its bytes, never by its bytes.
- Return type:
- mail_deduplicate.bug_report.shape(value)[source]¶
Describe a value by its bytes, its folding and its encoded words.
- Return type:
- mail_deduplicate.bug_report.verbatim(value)[source]¶
Show a value as it is, with its folding collapsed and its length capped.
A value holding non-ASCII bytes renders as
bytes, so each byte shows as an escape rather than as a lone surrogate.- Return type:
- mail_deduplicate.bug_report.mask_comments(value)[source]¶
Replace the text of each comment of a header value by its shape.
A comment is free text in parentheses, which RFC 5322 allows between the tokens of a structured header. Some mail clients write the name of their time zone there, after a
Date, and some their own name, afterMIME-Version. A comment can hold others, and a backslash quotes the character after it. A comment left open runs to the end of the value.- Return type:
- mail_deduplicate.bug_report.split_parameters(value)[source]¶
Split a header value on its semicolons, except the ones in a quoted string.
- mail_deduplicate.bug_report.structure(value)[source]¶
Show a
Content-TypeorContent-Dispositionvalue by its structure alone.The leading
type/subtypeor disposition shows as it is, and so do theSAFE_PARAMETERS. TheSHAPED_PARAMETERSkeep their name, and any other parameter shows as?, all of them with the shape of their value only.- Return type:
- mail_deduplicate.bug_report.header_value(name, value)[source]¶
Show a header value as its name allows: verbatim, by structure, or by shape.
A verbatim value still shows its comments by their shape.
- Return type:
- mail_deduplicate.bug_report.line_endings(raw)[source]¶
Name the line endings of a mail, with their counts when they are mixed.
- Return type:
- mail_deduplicate.bug_report.first_raw(part, name)[source]¶
The raw value of the first header of a part with that name.
A parsed header holding 8-bit bytes reads back as a
Headerobject, and its raw value as a string with lone surrogates, whichto_bytes()turns back to bytes.
- mail_deduplicate.bug_report.part_lines(part, depth=0)[source]¶
One line per MIME part, with the subparts of a multipart indented under it.
- mail_deduplicate.bug_report.fingerprint(raw)[source]¶
Describe a mail by its structure, with none of its content.
The description shows the size and line endings of the mail, then every header name, in order. Headers in
VERBATIM_HEADERSshow their value but for their comments, those inPARAMETER_HEADERStheir structure, and any other one only the shape of its value: length, kind of bytes, folding and encoded words. Then comes the MIME tree, with the type, charset, transfer encoding and kind of bytes of each part, the preamble and epilogue of each multipart, and the defects the parser found.Never raises: a mail too broken to describe gets a line saying so.
- Return type:
- mail_deduplicate.bug_report.fingerprint_mail(mail)[source]¶
The
fingerprint()of a mail, read again from its source.Never raises: a mail that cannot be read again gets a line saying so.
- Return type:
- mail_deduplicate.bug_report.box_format_of(item)[source]¶
The format of a box, or of the box a mail comes from.
- Return type:
- mail_deduplicate.bug_report.describe(expt)[source]¶
The class and message of an exception, on one line of capped length.
- Return type:
- mail_deduplicate.bug_report.compact_frames(tb)[source]¶
A traceback as
module:line in functionentries, outermost first.Module names say as much as file paths about the code, and nothing about where it is installed, so they are what the report shows. A method shows with its class, like
Maildir.add.
- mail_deduplicate.bug_report.culprit_function(tb)[source]¶
The name of the innermost function of mdedup in a traceback, or nothing.
It names the code to fix, even for an error that Python or a library raised from a call of mdedup. A generator expression, a comprehension or a lambda has no name of its own, so the named function nearest to it in the traceback stands for it.
- Return type:
- class mail_deduplicate.bug_report.Failure(step, error, frames, function='', culprits=(), formats=(), fingerprints=(), trace='')[source]¶
Bases:
objectAn error mdedup has no rule for, reduced to strings.
Strings can leave a worker process, and keep nothing alive: the mails, boxes and frames of the error are dropped as soon as it is captured.
- function: str = ''¶
The innermost function of mdedup the error went through, as
culprit_function()finds it.
- culprits: tuple[str, ...] = ()¶
The
repr()of each mail involved. They hold paths, so they never reach the report block.
- fingerprints: tuple[str, ...] = ()¶
The fingerprint of the first mails involved, or nothing for a failure that is not a sample.
- trace: str = ''¶
The full traceback, for the debug log, or nothing for a failure that is not a sample.
- property kind: tuple[str, str, str]¶
Failures of one kind share their step, exception class and raising line.
- property title: str¶
The title of an issue reporting the failure.
It names the exception class, the innermost function of mdedup in the traceback and the step. Reports of the same bug get the same title, so GitHub lists the issue already open for it while the next user types theirs. The function tells apart two bugs raising the same class in the same step, and unlike a line number, it does not change between releases.
- mail_deduplicate.bug_report.capture(expt, step, mails=(), sample=True)[source]¶
Reduce an error to a
Failure.Only a sample gets the fingerprints of its first mails and the full traceback: both cost far more than the rest.
- Return type:
- mail_deduplicate.bug_report.failure_lines(samples, title)[source]¶
The lines of a report detailing one kind of failure.
- class mail_deduplicate.bug_report.FailureLog[source]¶
Bases:
objectThe failures of a run: each one counted, the first
SAMPLESof a kind kept.Memory stays flat however many mails fail the same way, which a single bug can make all of them do.
- samples: dict[tuple[str, str, str], list[Failure]]¶
The first failures of each kind, in the order they happened.
- capture(expt, step, mails=())[source]¶
Reduce an error to a
Failure, sampled while its kind has room left.- Return type:
- add(failure)[source]¶
Count a failure, and keep it if its kind still has room for a sample.
- Return type:
- headline()[source]¶
The first line of the report, saying how much the failures set aside.
It also says that the run continued, as the word
errorreads to some users as a crash that stopped everything.- Return type:
- mail_deduplicate.bug_report.option_words(param, value)[source]¶
Render one parameter as typed on the command line, with its values masked.
A path or a regular expression can name the folders and correspondents of the user, so each shows as a placeholder.
- mail_deduplicate.bug_report.options_summary()[source]¶
The parameters of the run that do not come from their default, masked.
The parameters of Click Extra keep no value of their own in the context, and none of them changes what mdedup does to a mail, except
--jobs.- Return type:
- mail_deduplicate.bug_report.mdedup_version()[source]¶
The version of mdedup, as
mdedup --versionprints it.Click Extra appends to a development version the short hash of its commit, which it reads from
git, a hash baked in at build time, agit archiveexport, or the record of an install from a Git URL, like auvx --from git+...run.- Return type:
- mail_deduplicate.bug_report.report_url(title)[source]¶
The URL of the bug report form, with its title filled in.
- Return type:
- mail_deduplicate.bug_report.render_report(headline, title, details, sources)[source]¶
The message inviting the user to file a bug report, with the block to paste.
It links to the bug report form, with
titlefilled in. The block opens with the version, the platform, the options and the formats of the sources, then gives thedetailsof the error.- Return type:
- mail_deduplicate.bug_report.save_report(report)[source]¶
Write a bug report to a new file in the
REPORTS_FOLDERof the cache.A user who loses the output of a long run, to a closed terminal or a short scrollback, still finds the report there. The file holds the report as it is printed, with no path and no mail content, so it can be attached to an issue whole.
Each report gets a new file, named after the time it was saved.
mkstemp()adds random characters to the name and never reuses one, so two reports of the same second, like a run’s failures and its statistics check, keep a file each.Returns
Noneif the file cannot be written: the printed report then stands alone.
- mail_deduplicate.bug_report.log_report(level, headline, title, details, sources)[source]¶
Log the report
render_report()builds, and save it withsave_report().The path of the saved file comes last, alone on its line, so it is the last thing a user sees of the run, and easy to copy. It stays out of the file.
- Return type:
- mail_deduplicate.bug_report.blame(mail, step)[source]¶
Attach the mail being processed to any error escaping the block.
The error goes on to stop the run, and
report_unexpected_errors()names the mail. It also fingerprints the mail in the bug report, except for anOSError, which gets no bug report.
- mail_deduplicate.bug_report.report_unexpected_errors(dedup)[source]¶
End the run with a one-line error or a bug report, whatever escapes the block.
The exceptions of Click pass through: they are how a command exits, or refuses an input. An
OSErrorcomes from the system, like a missing permission or a full disk, so the user can fix it: it ends the run with a one-line error. Any other error is a bug. Its traceback is logged with the report inviting the user to file it, and the run exits with code 1.
mail_deduplicate.cache module¶
Persistent cache of mail hashes, so a second run does not re-parse unchanged mails.
Hashing dominates a run, and it is pure work: the same mail, hashed with the same settings, always produces the same result. Storing that result lets a later run skip opening and parsing every mail it has already seen. See: https://github.com/kdeldycke/mail-deduplicate/issues/87
Correctness rests on two independent guards:
A fingerprint of every setting feeding the hash. Anything else invalidates the whole database, as no entry produced under different settings can be trusted.
A per-mail staleness key of
(size, mtime), taken from the file backing the mail before it is read, so a mail modified mid-run is re-hashed by the next one instead of being trusted.
- mail_deduplicate.cache.HASHING_VERSION = '1'¶
Bumped whenever a change to the hashing, or to the size it memoizes, makes the entries of earlier versions wrong.
Such an entry reads as valid, and would group its mail differently from the mails hashed afresh: the whole database is discarded instead, as for a change of settings.
- mail_deduplicate.cache.SCHEMA_VERSION = '1'¶
Bumped whenever the table layout changes, to discard databases of older shapes.
- mail_deduplicate.cache.CACHED_SETTINGS = ('hash_headers', 'hash_body', 'raw_headers', 'time_source')¶
Configuration keys whose value changes what gets cached.
hash_headers,hash_bodyandraw_headerschange the hash itself.time_sourcechanges the timestamp memoized alongside it. The minimal-headers floor is derived from the number of hash headers, so it is covered by them.
- class mail_deduplicate.cache.StaleKey(size, mtime_ns)[source]¶
Bases:
NamedTupleIdentifies the version of the file backing a mail.
Create new instance of StaleKey(size, mtime_ns)
- class mail_deduplicate.cache.CacheEntry(mail_hash, timestamp, mail_size)[source]¶
Bases:
NamedTupleWhat is worth keeping about a hashed mail.
The hash is the point. The two scalars are what
dehydrate()memoizes to spare the later steps a re-read, so restoring them is what actually lets a cached mail stay unparsed.mail_sizeis only known when the body was hashed, andtimestampisNonefor a mail whoseDateheader could not be parsed.Create new instance of CacheEntry(mail_hash, timestamp, mail_size)
- mail_deduplicate.cache.default_cache_dir()[source]¶
Location of the cache database, following each platform’s conventions.
See
click_extra.carapace.get_cache_dir()for the location on each platform.- Return type:
- mail_deduplicate.cache.settings_fingerprint(conf)[source]¶
Digest of every setting that changes what a cached entry would hold.
The two versions go in with the settings, so a database of an earlier release is discarded the same way when either moved.
- Return type:
- mail_deduplicate.cache.open_cache(conf)[source]¶
Opens the hash cache the configuration asks for, if it asks for one at all.
A cache only ever saves work, so a database that cannot be opened, on a read-only or full filesystem for instance, is reported and skipped instead of taking the whole run down with it.
- class mail_deduplicate.cache.HashCache(path, fingerprint)[source]¶
Bases:
objectA mail hash cache backed by SQLite, keyed by mail identity.
Opened for the whole run and committed once at the end, so an interrupted run leaves the database as it found it.
- WRITE_BATCH: int = 512¶
Rows buffered before being handed to SQLite in one
executemany().Inserting one row at a time costs more in statement overhead than in storage.
- LOCK_TIMEOUT: float = 30.0¶
Seconds a run waits for another one to release the database.
Runs sharing a database only ever contend on the single write burst each of them performs at the end, so waiting it out beats failing. See
commit()for what happens when the wait is not enough.
- lookup(box, mail_id)[source]¶
Returns the entry cached for a mail, if it is still valid.
The staleness key is taken here, before the caller reads the mail, so a mail modified between this check and its hashing is caught by the next run rather than trusted.
- Return type:
- store(source_path, mail_id, entry)[source]¶
Record the hash of a freshly read mail, under the key taken by
lookup().Silently skipped for a mail that was never looked up, as there is then no staleness key that provably predates the read.
- Return type:
- prune()[source]¶
Drops the entries of mails and boxes that are no longer there.
Two kinds of leftovers accumulate. A box that was deleted or moved keeps every one of its entries, and a mail deleted from a box that is still around keeps its own. Both are dropped here, and how many were removed is recorded as
prunedfor the run to report.Only the boxes visited by this run are considered for their individual mails: every mail of a box that was not opened is missing from
seenfor the plain reason that nobody looked, which is not evidence that it is gone.- Return type:
- forget(source_path, mail_id)[source]¶
Drop the staleness key of a mail that will not be recorded.
- Return type:
- commit()[source]¶
Flush everything recorded during the run, in one transaction.
Returns whether it went through. By this point the deduplication itself is done, so a database another run is holding, or a disk that filled up while we worked, costs the next run its head start and nothing more: it must not take down a run that has already produced its results.
- Return type:
mail_deduplicate.cli module¶
The mdedup command: its options, their validation and the run Config.
- mail_deduplicate.cli.DEFAULT_HASH_HEADERS: tuple[str, ...] = ('Date', 'From', 'To', 'Subject', 'MIME-Version', 'Content-Type', 'Content-Disposition', 'User-Agent', 'X-Priority', 'Message-ID')¶
Default ordered list of headers to use to compute the unique hash of a mail.
By default we choose to exclude:
CCSince
mailmanapparently sometimes trims list members from theCCheader to avoid sending duplicates. Which means that copies of mail reflected back from the list server will have a differentCCto the copy saved by the MUA at send-time.BCCBecause copies of the mail saved by the MUA at send-time will have
BCC, but copies reflected back from the list server won’t.Reply-ToSince a mail could be
CC’d to two lists with differentReply-Tomunging options set.
- mail_deduplicate.cli.DEFAULT_MINIMAL_HEADERS = 4¶
Cap on the number of headers that must be present in a mail to compute a solid hash.
The per-mail floor is
min(DEFAULT_MINIMAL_HEADERS, len(hash_headers)). A near-empty or corrupted mail whose hash would rest on too few headers gets no hash, and the run keeps it like a mail without duplicates. The floor relaxes automatically when--hash-headernarrows the hash to fewer headers than this cap.
- class mail_deduplicate.cli.Preset(*values)[source]¶
Bases:
StrEnumNamed sets of option values, each a starting point for a run.
A preset is an alias of the options
PRESET_OPTIONSlists for it.- CONSERVATIVE = 'conservative'¶
Treats as copies only the mails whose bytes are identical.
- DEFAULT = 'default'¶
Keeps the built-in defaults.
- AGGRESSIVE = 'aggressive'¶
Trusts the
Message-IDheader alone to group copies.
- mail_deduplicate.cli.PRESET_OPTIONS: dict[Preset, tuple[tuple[str, str | bool], ...]] = {Preset.AGGRESSIVE: (('--hash-header', 'Message-ID'),), Preset.CONSERVATIVE: (('--raw-headers', True), ('--hash-body', 'raw'), ('--size-threshold', '-1'), ('--content-threshold', '-1'), ('--strategy', 'select-one')), Preset.DEFAULT: ()}¶
The options each preset stands for, spelled as on the command line.
apply_preset()gives each of them its value, unless the user set that option. A value that relies on other values of the same preset applies only while all of them apply: seeDEPENDENT_OPTIONS.The conservative preset hashes the bytes of the whole mail, which only identical copies share. The thresholds then have nothing left to catch, and turning them off spares a second read of each mail. When the user replaces one of its two hashing options, copies that differ can share a hash, so the thresholds stay on. Identical copies are interchangeable, so the preset also keeps one of each.
The aggressive preset trusts
Message-IDalone, so the copies whose other headers a mailing list or a gateway rewrote still share a hash. The thresholds stay the only check against two mails that reuse an ID. Its copies can differ, so which one to keep stays the user’s choice.
- mail_deduplicate.cli.LINKED_OPTIONS: tuple[frozenset[str], ...] = (frozenset({'hash_headers', 'raw_headers'}),)¶
Options that make one choice between them, by their name.
--raw-headershashes every header, so a--hash-headerlist has nothing left to select. The user setting either option decides the headers, so a preset value for the other one gives way too.
- mail_deduplicate.cli.DEPENDENT_OPTIONS: dict[str, frozenset[str]] = {'content_threshold': frozenset({'hash_body', 'raw_headers'}), 'size_threshold': frozenset({'hash_body', 'raw_headers'})}¶
Options whose preset value relies on the preset values of others, by their name.
A preset value of such an option applies only while each option it relies on keeps the value of the same preset: the preset gives that value, or the user gives the same one. When one of them gives way, the option gives way too, and keeps its default or the user’s value. The rule ignores an option that the preset does not set.
The thresholds can stay off only while the hash covers every byte of a mail, which
--raw-headersand--hash-body rawdo together.
- mail_deduplicate.cli.QUIET_OVERRIDES = frozenset({'strategies'})¶
Options the user can set over a preset with no warning, by their name.
The strategy decides which copy stays, not what counts as a copy.
- mail_deduplicate.cli.preset_word(flag, value)[source]¶
One option of a preset, as typed on the command line.
- Return type:
- mail_deduplicate.cli.preset_words(preset)[source]¶
The options a preset stands for, as typed on the command line.
- Return type:
- class mail_deduplicate.cli.Config[source]¶
Bases:
TypedDictHolds global configuration.
- hash_body: BodyHasher¶
- time_source: TimeSource¶
- mail_deduplicate.cli.normalize_headers(ctx, param, value)[source]¶
Validate headers provided as parameters to the CLI.
Headers are case-insensitive in Python implementation, so we normalize them to lower-case.
We then deduplicate them, while preserving order.
Mail headers are expected to be composed of one or more ASCII characters between 33 and 126 (both inclusive) according to RFC-5322.
- mail_deduplicate.cli.unique_strategies(ctx, param, value)[source]¶
Deduplicate strategies provided as parameters to the CLI, preserving order.
Strategies are deduplicated by the selection function they point to, so repeating a strategy already listed under one of its aliases is ignored too.
- mail_deduplicate.cli.compile_regexp(ctx, param, value)[source]¶
Validate and compile regular expression provided as parameters to the CLI.
- mail_deduplicate.cli.PATH_STRATEGIES = (Strategy.DISCARD_MATCHING_PATH, Strategy.DISCARD_NON_MATCHING_PATH, Strategy.SELECT_MATCHING_PATH, Strategy.SELECT_NON_MATCHING_PATH)¶
Strategies relying on the
-r/--regexpparameter.
- mail_deduplicate.cli.EXPORT_ACTIONS = (Action.COPY_SELECTED, Action.COPY_DISCARDED, Action.MOVE_SELECTED, Action.MOVE_DISCARDED)¶
Actions relying on the
-E/--exportparameter.
- mail_deduplicate.cli.echo_step(title)[source]¶
Print the heading of a pipeline step, after a blank line.
The blank line is printed on its own, outside the heading’s style. Styled as part of the heading, its escapes open the style on the line above the text, so any tool reading the output line by line (a pager,
grep, a capture cut with--head) finds a style open on a line that closes it nowhere.
- mail_deduplicate.cli.human_join(items)[source]¶
Renders IDs as an English enumeration, for the help screen.
Both tuples above are the single source of truth of which strategies and actions take an extra parameter, so the options mentioning them read the list from there instead of spelling it out again and drifting from it.
- Return type:
- class mail_deduplicate.cli.AnyValueIn(param_name, targets, label, negate=False)[source]¶
Bases:
PredicateCondition that is true when any of a parameter’s values is in a target set.
The parameter’s resolved value is inspected, so defaults count, not only user-provided values. Single values and
multiple=Truetuples are normalized to a common shape. Setnegateto invert the membership test: true when none of the values is in the target set.Whatever
negatesays, the condition silently evaluates to false when no mail source is provided: the CLI then only prints its help screen and exits, so no parameter combination deserves a validation error.The same applies in
-H/--hash-onlymode: selection and action steps never run there, so options they require (like--exportfor the default copy-selected action) must not be demanded. Options from those steps are separately reported as ignored by the hash-only code path.
- class mail_deduplicate.cli.MdedupCommand(*args, version_fields=None, config_schema=None, config_strict=False, schema_strict=False, fallback_sections=(), config_validators=(), included_params=None, excluded_params=None, config_cascade=False, extra_option_at_end=True, option_priorities=None, populate_auto_envvars=True, extra_keywords=None, excluded_keywords=None, examples=(), **kwargs)[source]¶
Bases:
CommandLike
cloup.Command.__init__, with Click Extra’s defaults and parameters.- Parameters:
version_fields (
dict[str,Any] |None) – dictionary ofVersionOptiontemplate field overrides forwarded to the version option. Accepts any field fromVersionOption.template_fields(likeprog_name,version,git_branch). Lets you customize--versionoutput from the command decorator without replacing the defaultparamslist.config_strict (
bool) – forwarded to the defaultConfigOption’sstrictsetting: configuration keys not matching any CLI parameter raise an error instead of being silently ignored. Like the otherconfig_*and*_paramsforwards, it spares you from replacing the whole defaultparamslist to customize the config option.config_cascade (
bool) – forwarded to the defaultConfigOption’scascadesetting: auto-discovery layers every configuration file it finds, the most local one winning on key lookup, instead of stopping at the first.excluded_params (
Sequence[str] |None) – additional parameter IDs to block from configuration files, merged into the defaultConfigOption’sexcluded_paramsblocklist. Additive, unlike the option-levelexcluded_paramswhich replaces the default blocklist entirely. Items are fully-qualified parameter IDs (likemycli.mail_sources). Mutually exclusive withincluded_params.extra_keywords (
HelpKeywords|None) – aHelpKeywordsinstance whose entries are merged into the auto-collected keyword set. Use this to inject additional strings for help screen highlighting.excluded_keywords (
HelpKeywords|None) – aHelpKeywordsinstance whose entries are removed from the auto-collected keyword set. Use this to suppress highlighting of specific strings.examples (
Sequence[Sequence[str]]) – a sequence of(description, command)string pairs showing the command in use. They are rendered in anExamples:section of the help screen, in the man page, and in every –help-format rendering. A malformed pair raisesTypeErrorhere, at command construction, rather than on the first--helpa user runs.extra_option_at_end (
bool) – reorders all parameters attached to the command, by moving all instances ofExtraOptionat the end of the parameter list. The original order of the options is preserved among themselves.option_priorities (
Mapping[str,float] |None) – maps an option to its priority in the help screen, relative toDEFAULT_PRIORITY, lowest shown first. Keys are matched against each parameter’s long and short flags first, then its destination name, so the--config/--no-configpair (which shares theconfigdestination) stays addressable one flag at a time. Presentation only:self.params, and with it the order callbacks are evaluated in, is left alone. Positional arguments are never reordered, their sequence being part of the command’s grammar.populate_auto_envvars (
bool) – forces all parameters to have their auto-generated environment variables registered. This addresses the shortcoming ofclickwhich only evaluates them dynamically. Once registered, the auto-generated environment variables get displayed in the help screen, fixing click#2483 issue. On Windows, environment variable names are case-insensitive, so they are normalized to uppercase.
By default, these Click context settings are applied:
auto_envvar_prefix = self.name(Click feature)Auto-generate environment variables for all options, using the command ID as prefix. The prefix is normalized to be uppercased and all non-alphanumerics replaced by underscores.
help_option_names = ("--help", "-h")(Click feature)Allow help screen to be invoked with either –help or -h options.
show_default = True(Click feature)Show all default values in help screen.
Additionally, these Cloup context settings are set:
align_option_groups = True(Cloup feature)Aligns option groups in help screen.
Every command carries the sections of
DEFAULT_OPTION_GROUPS, so leaving each to compute its own column width would step the help text left and right from one section to the next. Aligned, a command’s own options keep the column click-extra’s widest option sets, which is what thedefault_options_*_helpfixtures ofclick_extra.pytestmatch against.show_constraints = True(Cloup feature)show_subcommand_aliases = True(Cloup feature)
Click Extra also adds its own
context_settings:show_choices = None(Click Extra feature)If set to
TrueorFalse, will force that value on all options, so we can globally show or hide choices when prompting a user for input. Only makes sense for options whosepromptproperty is set.Defaults to
None, which will leave all options untouched, and let them decide of their ownshow_choicessetting.show_envvar = None(Click Extra feature)If set to
TrueorFalse, will force that value on all options, so we can globally enable or disable the display of environment variables in help screen.Defaults to
None, which will leave all options untouched, and let them decide of their ownshow_envvarsetting. The rationale being that discoverability of environment variables is enabled by the--paramsoption, which is active by default on extra commands. So there is no need to surcharge the help screen.This addresses the click#2313 issue.
To override these defaults, you can pass your own settings with the
context_settingsparameter:@command( context_settings={ "show_default": False, ... } )
- mail_deduplicate.cli.flag_names(param)[source]¶
Every spelling of an option, joined as the help screen lists them.
- Return type:
- mail_deduplicate.cli.user_set(ctx, name)[source]¶
Whether the user gave a parameter its value, from any source but its default.
A value from a configuration file or the environment counts as set.
- Return type:
- mail_deduplicate.cli.apply_preset(ctx, preset)[source]¶
The values a preset gives the options the user left at their default.
An option the user set keeps its value. The run warns about each one that overrides the preset with another value, unless
QUIET_OVERRIDESlists it. The options of aLINKED_OPTIONSgroup give way together. A value that relies on other values of the preset, asDEPENDENT_OPTIONSlists, gives way with any of them, and the run names the options that then keep their defaults.
mail_deduplicate.deduplicate module¶
The deduplication pipeline: group mails by hash, settle each set, keep score.
The Deduplicate orchestrator drives the whole run. Both of its expensive steps,
hashing and selection, can fan out across worker processes: the module-level
_-prefixed functions are the halves that run inside a worker.
- mail_deduplicate.deduplicate.step_progressbar(**kwargs)[source]¶
A progress bar for one step of the run, drawn on a terminal only.
Off a terminal, Click prints a bar’s label once instead of the bar. Each step already opens on a heading saying the same thing, so in a log, a pipe or a capture the label is a bare line repeating it. Hiding the bar there drops that line, and leaves a terminal the bar
--progressasks for.The bar draws on
sys.stderr, asclick_extra.progressbar()makes it, unlessfilenames another stream: that is the stream checked.- Parameters:
kwargs (
Any) – whatclick_extra.progressbar()takes.- Return type:
ProgressBar[Any]
- mail_deduplicate.deduplicate.path_order(mail)[source]¶
Sort key ranking mails by path, then by ID among the mails of one file.
A set of mails iterates in an order that changes from one run to the next. Sorted by this key, the same mails come out in the same order on every run.
- class mail_deduplicate.deduplicate.Stat(*values)[source]¶
Bases:
EnumAll tracked statistics.
The member’s name carries the category as its
MAIL_/SET_prefix, and its value the description shown in the final report.- MAIL_FOUND = 'Total number of mails encountered from all mail sources.'¶
- MAIL_REJECTED = 'Number of mails rejected individually because they could not be read.'¶
- MAIL_UNHASHED = 'Number of mails with too few of the hashed headers to compute a hash. They joined no duplicate set, and were kept in the final selection like unique mails.'¶
- MAIL_FAILED = 'Number of mails set aside, and left untouched, because hashing them failed on an unexpected error. The bug report after this table tells how to report it.'¶
- MAIL_RETAINED = 'Number of valid mails parsed and retained for deduplication.'¶
- MAIL_HASHES = 'Number of unique hashes.'¶
- MAIL_UNIQUE = 'Number of unique mails (which were automatically added to selection).'¶
- MAIL_DUPLICATES = 'Number of duplicate mails (sum of mails in all duplicate sets with at least 2 mails).'¶
- MAIL_SKIPPED = 'Number of mails ignored in the selection step because the whole set they belong to was skipped.'¶
- MAIL_DISCARDED = 'Number of mails discarded from the final selection.'¶
- MAIL_SELECTED = 'Number of mails kept in the final selection on which the action will be performed.'¶
- MAIL_COPIED = 'Number of mails copied from their original mailbox to another.'¶
- MAIL_MOVED = 'Number of mails moved from their original mailbox to another.'¶
- MAIL_DELETED = 'Number of mails deleted from their mailbox in-place.'¶
- MAIL_HARDLINKED = 'Number of mails replaced in-place by a hardlink to the copy kept in their duplicate set.'¶
- MAIL_HARDLINK_SKIPPED = 'Number of mails left untouched by the hardlinking action, because they could not be linked to their copy or already were.'¶
- SET_TOTAL = 'Total number of duplicate sets.'¶
- SET_SINGLE = 'Total number of sets containing only a single mail with no applicable strategy. They were automatically kept in the final selection.'¶
- SET_SKIPPED_SIZE = 'Number of sets skipped from the selection process because they were too dissimilar in size.'¶
- SET_SKIPPED_CONTENT = 'Number of sets skipped from the selection process because they were too dissimilar in content.'¶
- SET_SKIPPED_TIMESTAMP = 'Number of sets skipped from the selection process because a timestamp could not be derived for some of their mails.'¶
- SET_SKIPPED_STRATEGY = 'Number of sets skipped from the selection process because the strategy could not be applied.'¶
- SET_SKIPPED_ERROR = 'Number of sets skipped from the selection process because reading or comparing their mails failed on an error.'¶
- SET_DEDUPLICATED = 'Number of valid sets on which the selection strategy was successfully applied.'¶
- exception mail_deduplicate.deduplicate.SizeDiffAboveThreshold[source]¶
Bases:
ExceptionDifference in mail size is greater than threshold.
- exception mail_deduplicate.deduplicate.ContentDiffAboveThreshold[source]¶
Bases:
ExceptionDifference in mail content is greater than threshold.
- exception mail_deduplicate.deduplicate.MissingTimestamps[source]¶
Bases:
ExceptionSome mails of a set have no timestamp for time-based strategies to compare.
Happens for mails without a parseable
Dateheader, when the timestamp is sourced from it.
- class mail_deduplicate.deduplicate.BodyHasher(*values)[source]¶
Bases:
StrEnumEnumeration of available body hashing methods.
Each value interprets the body one step further than the one before it.
skipleaves the body out.rawhashes its bytes as the box holds them, with line endings reduced to LF.decodedundoes the transfer encodings and decodes the text, so copies that differ in their encoding alone hash the same.normalizedalso strips all whitespace first.- SKIP = 'skip'¶
- RAW = 'raw'¶
- DECODED = 'decoded'¶
- NORMALIZED = 'normalized'¶
- property function: Callable[[DedupMailMixin], str]¶
The callable producing this member’s body hash for one mail.
- class mail_deduplicate.deduplicate.DuplicateSet(hash_key, mail_set, conf)[source]¶
Bases:
objectA set of mails sharing the same hash.
Implements all the safety checks required before we can apply any selection strategy.
Load-up the duplicate set of mail and freeze pool.
Once loaded-up, the pool of parsed mails is considered frozen for the rest of the duplicate set’s life. This allows aggressive caching of lazy instance attributes depending on the pool content.
- selection: set[DedupMailMixin]¶
Mails selected after application of selection strategy.
- unique: set[DedupMailMixin]¶
The subset of the selection no strategy ever ruled on.
A mail alone in its set has no copy to be compared to, so it is kept without any criterion picking it over another. Held apart from the rest of the selection so a destructive action can leave it alone. See: https://github.com/kdeldycke/mail-deduplicate/issues/1053
- discard: set[DedupMailMixin]¶
Mails discarded after application of selection strategy.
- conf¶
Configuration shared from the main deduplication process.
- pool: frozenset[DedupMailMixin]¶
Pool referencing all duplicated mails and their attributes.
- property timestamps: tuple[float, ...][source]¶
Returns the timestamps of all mails in the set.
Raises
MissingTimestampsif a timestamp could not be derived for some mails, naming them so users can locate and fix them. See: https://github.com/kdeldycke/mail-deduplicate/issues/132
- check_differences()[source]¶
Checks the mails of the set against each other for size and content.
Their differences must stay within the limits the thresholds set.
Instead of rejecting the whole set on the first offending pair, the mails involved in the most offending pairs are greedily set aside until every remaining pair passes the thresholds. This keeps a single outlier from preventing the deduplication of the true copies sharing its set. See: https://github.com/kdeldycke/mail-deduplicate/issues/851
Returns the mails to set aside, empty if the whole pool already passes.
Raises
SizeDiffAboveThresholdorContentDiffAboveThresholdif fewer than 2 mails would remain, in which case there is no coherent core of duplicates and the whole set is to be skipped, as before.- Return type:
- diff(mail_a, mail_b)[source]¶
Return the difference between two mails’ decoded body, in bytes.
The unified diff of the two bodies is measured in UTF-8, the unit of
--content-thresholdand ofsize.Todo
Rewrite the diff algorithm to not rely on naive unified diff result parsing.
- Return type:
- pretty_diff(mail_a, mail_b)[source]¶
Returns a verbose unified diff between two mails’ decoded body.
The body lines carry no line ending, so no diff line gets one either: they are joined with one instead.
Each line is styled by the active theme, the way
git diffcolors its own: a removed line as an error, an added one as a success, the hunk range as a range, and the two file headers as structure. The log handler strips the styling wherever color is off.- Return type:
- select()[source]¶
Settle which mails of the set are selected and which are discarded.
Run preliminary checks, then apply the strategies to the pool of mails, each in turn until one produces a proper selection.
The process results in two subsets of mails: the selected and the discarded.
- Return type:
- select_or_skip(failures)[source]¶
Run
select(), and skip the whole set if it raises an error.Returns the error, ready for the bug report, or
Nonewhen the selection went through or the error needs no report. Seeskip_on_error().
- skip_on_error(expt, pool, failures)[source]¶
Skip the whole set after an error, as a set failing a safeguard is skipped.
Whatever the interrupted selection recorded is dropped, and the full
poolis accounted for, so none of its mails is acted upon. AnOSErrormeans a mail could not be read again, which needs no bug report. Any other error is captured for the report and returned.
- class mail_deduplicate.deduplicate.HashedMail(mail_hash, timestamp, mail_size, rejection, failure=None, unhashed=None)[source]¶
Bases:
NamedTupleWhat a hashing worker sends back for one mail.
Deliberately nothing but scalars, and the strings of a
Failurefor a mail that hit an unexpected error: the parsed message stays in the worker and dies with the task, so a few dozen bytes cross the process boundary instead of the whole mail. Which mail an answer belongs to is not carried either, asfan_out()hands every answer back alongside the task it came from. The parent rebuilds the same dehydrated stub it would have produced itself, exactly as it does for a mail restored from the cache.Create new instance of HashedMail(mail_hash, timestamp, mail_size, rejection, failure, unhashed)
- class mail_deduplicate.deduplicate.HashOutcome(mail, mail_hash, rejection=None, failure=None, unhashed=None)[source]¶
Bases:
NamedTupleWhat the hashing step makes of one mail, ready to be merged into the run.
At most one of
mail_hash,rejection,failureandunhashedis set: a mail is retained, rejected for a reason that needs no bug report, set aside after an error that does, or kept without a hash, as it has too few headers to compute one.Create new instance of HashOutcome(mail, mail_hash, rejection, failure, unhashed)
- mail: DedupMailMixin¶
Alias for field number 0
- mail_deduplicate.deduplicate.unreadable(expt)[source]¶
Words the rejection of a mail whose file the system refused to read.
- Return type:
- class mail_deduplicate.deduplicate.MailMeta(source_path, mail_id, path, timestamp, mail_size)[source]¶
Bases:
NamedTupleAll a worker needs to rebuild one mail of a duplicate set from its own file.
Create new instance of MailMeta(source_path, mail_id, path, timestamp, mail_size)
- class mail_deduplicate.deduplicate.SelectedSet(selected, unique, discarded, stats, records, failure=None)[source]¶
Bases:
NamedTupleWhat a selection worker sends back for one duplicate set.
Mails are named rather than returned: the parent already holds them, and shipping them back would mean pickling every message it worked so hard not to keep.
Create new instance of SelectedSet(selected, unique, discarded, stats, records, failure)
- class mail_deduplicate.deduplicate.Deduplicate(conf)[source]¶
Bases:
objectLoad mails, search for duplicates, apply the strategies and perform the action.
Similar messages sharing the same hash are grouped together in a
DuplicateSet.- CHUNK_SIZE: int = 200¶
Mails handed to a hashing worker in one go.
Each queue round-trip costs far more than hashing a single mail, so tasks travel in chunks. Large enough to make that cost disappear, small enough that the last worker to finish does not hold up the others.
- SET_CHUNK_SIZE: int = 32¶
Duplicate sets handed to a selection worker in one go.
Smaller than the hashing chunk because a set is several mails’ worth of work, so fewer of them already amortize the same round-trip.
- sources: dict[str, Mailbox]¶
Index of mail sources by their full, normalized path. So we can refer to them in Mail instances. Also have the nice side effect of natural deduplication of sources themselves.
- mails: dict[str, list[DedupMailMixin]]¶
All mails grouped by hashes.
Grouped in lists rather than sets: mails carry no value equality, so a set only ever deduplicated by object identity, which the single pass over each box already rules out. A one-element list also costs 64 bytes where a set costs 216, and most hashes group a single mail.
- selection: set[DedupMailMixin]¶
Mails selected after application of selection strategy.
- unique: set[DedupMailMixin]¶
The subset of the selection no strategy ever ruled on.
It holds the mails that have no duplicate, which each duplicate set contributes here: see
DuplicateSet.unique. It also holds the mails with too few headers to be hashed, which join no duplicate set at all.
- discard: set[DedupMailMixin]¶
Mails discarded after application of selection strategy.
- link_targets: dict[DedupMailMixin, DedupMailMixin]¶
Maps each discarded mail to the selected mail it can be hardlinked to.
Left empty unless the configured action consumes it: see
track_link_targets.
- conf¶
Configuration shared across the deduplication process.
- track_link_targets: bool¶
Whether each discarded mail has to be paired with a selected one.
Only the hardlinking action needs that pairing, and it costs one dictionary entry per discarded mail, so it is settled once here and only recorded when something reads it.
- failures¶
The errors no rule explains, each of which put a mail or a duplicate set aside.
- step¶
What the run is doing, named by the bug report of an error that stops it.
- source_formats: Counter[str]¶
Number of boxes opened in each format, for the bug report.
Counted as the boxes open, since closing them empties
sources.
- cache: HashCache | None¶
Cross-run cache of mail hashes, when the user opted in with
--cacheand the database could be opened.
- restore_cached(box, mail_id, entry)[source]¶
Rebuild a hashed mail from its cache entry, without opening its file.
Produces the same dehydrated stub the hashing step would have left behind, with the scalars the later steps rely on already memoized. Anything else those steps need is re-read from the box on demand, as for any other mail.
- Return type:
- blank_stub(box, mail_id)[source]¶
An empty mail carrying only its identity, ready to be filled in.
Stands in for a mail this process never parsed, because its content came from the cache or from a worker that has already thrown it away.
- Return type:
- parallel_boxes()[source]¶
The sources whose mails a worker process could hash on its own.
Only folder-based boxes qualify. Their mails each own a file, which a worker opens by path, sharing nothing. Mails of a file-based box are byte ranges of one file that a single handle seeks through, so handing them out would mean several processes seeking the same descriptor.
Returns an empty list unless every source qualifies, so a run is either wholly parallel or wholly sequential rather than silently half of each.
- worker_pool(jobs, initializer, initargs, verb, doing)[source]¶
Hand out a pool of worker processes prepared by the initializer.
The pool shuts down however the block it wraps ends.
Yields
Noneinstead when the pool cannot be started, which is the case in some frozen or sandboxed environments: the initializer is then run right here, so the worker functions find the state they expect, and the caller degrades to sequential calls instead of dying.- Return type:
- fan_out(pool, worker, tasks, window, chunksize)[source]¶
Yields each task back alongside its worker’s answer.
Tasks pair what the parent keeps with the payload its worker receives, and are handed out a window at a time rather than all at once:
map()consumes whatever it is given immediately, so passing the whole corpus would hold a task per mail, undoing the flat memory the rest of the run maintains. The window is wide enough that every worker always has chunks queued behind it.With or without a pool, answers come back in submission order, so whatever the caller aggregates comes out identical to a sequential run at any number of workers.
- uncached(boxes, absorb, progress)[source]¶
Yields the identity of every mail the cache cannot answer for.
A mail the cache can restore is absorbed here and never even opened, which is the whole point of keeping the cache. Lazy, and driven from the main process alone: neither box objects nor the cache are safe for concurrent access.
- hash_in_parallel(jobs, absorb, progress)[source]¶
Hash every uncached mail across a pool of worker processes.
Threads cannot do this job: what hashing spends itself on is Python-level work that the interpreter lock serializes, so fanning it out across threads only adds contention. Processes sidestep the lock, and the mail never has to travel: a worker opens its own file and sends back a hash and two scalars.
- Return type:
- adopt_hashed(box, mail_id, result)[source]¶
Turn a worker’s answer back into the mail stub this process works with.
- Return type:
- add_source(source_path)[source]¶
Registers a source of mails, validates and opens it.
Duplicate sources of mails are not allowed, as when we perform the action, we use the path as a unique key to tie back a mail from its source.
A source that cannot be opened or listed raises
MailSourceError: the error comes from the source or its format, which the user can fix.- Return type:
- hash_all()[source]¶
Read the mails of all sources, compute their hashes and group them by hash.
Displays a progress bar as the operation might be slow.
Each mail is dehydrated as soon as it is hashed, so whatever the size of the corpus, only a lightweight stub of every mail is retained. See: https://github.com/kdeldycke/mail-deduplicate/issues/761
Hashing fans out across worker processes when
--jobsresolves above 1 and every source is a folder-based box; otherwise mails stream through this process one at a time, which is also the lowest-memory path. Box listing and the hash cache always stay in this process, as neither is safe for concurrent access, so the speedup is largest where the hashing itself is the cost, with--hash-body decoded/normalized.- Return type:
- build_sets()[source]¶
Build the selected and discarded sets from each duplicate set.
The selection is settled one duplicate set at a time, to keep the memory footprint low and make the log easier to read.
- Return type:
- settle(hash_key, mail_set)[source]¶
Settle one set in this process, and merge its verdict into the run.
The set goes through the thresholds and the strategies.
- Return type:
- log_set_heading(hash_key, mail_count)[source]¶
Announce a duplicate set, at a level reflecting whether it holds copies.
Styling the heading is not free, and most sets hold a single mail and so report at debug level, where the result is thrown away: only pay for it when it is actually logged.
- Return type:
- release(mail_set)[source]¶
Drop the content the thresholds and strategies pulled back from the boxes.
Iterating on the original set covers skipped sets and set-aside mails too, which land in neither selection nor discard. See: https://github.com/kdeldycke/mail-deduplicate/issues/362
- Return type:
- record_link_targets(selection, discard)[source]¶
Pair every discarded mail of a set with a selected mail of that same set.
Hardlinking a discarded mail only makes sense against a copy that survives the very set both were found in, and that pairing is the one thing the flat selection and discard sets no longer say once every set has been settled.
A strategy is free to keep several mails, so the target is the one with the lowest path: a run then links to the same copy however the sets came back, which matters when the selection is spread over a pool of processes.
Iterables are only walked once the run is known to need them, so a call from a non-hardlinking run costs nothing beyond the arguments themselves.
- Return type:
- select_in_parallel(jobs, progress)[source]¶
Apply the selection to every duplicate set across a pool of processes.
Duplicate sets share nothing with one another, so a set is the natural unit of work. Only sets holding copies are handed out: a set of one is settled without reading anything, and would cost more to ship than to decide.
- Return type:
- adopt_selection(hash_key, mail_set, result)[source]¶
Merge a worker’s verdict on one set back into this process.
The heading and the worker’s own messages are said here, in that order, so the log reads as it would have from a sequential run however the sets were spread out.
- Return type:
- close_all(flush=True)[source]¶
Close all open boxes, and the hash cache if one was opened.
With
flushoff, the pending changes of the boxes stay unwritten: seerelease_box(). Closed boxes and cache are dropped, so calling this again does nothing.- Return type:
- report_failures()[source]¶
Log the bug report of the mails and sets the run set aside.
Returns whether there were any, so the run can end with an error code.
- Return type:
mail_deduplicate.mail module¶
A mail wrapped with the properties deduplication needs.
They are the canonical hash, the normalized headers, the timestamp, the size, and the dehydration that keeps memory flat.
- exception mail_deduplicate.mail.TooFewHeaders[source]¶
Bases:
ExceptionNot enough headers were found to produce a solid hash.
- class mail_deduplicate.mail.TimeSource(*values)[source]¶
Bases:
StrEnumEnumeration of all supported mail timestamp sources.
- DATE_HEADER = 'date-header'¶
Timestamp sourced from the message’s
Dateheader.
- CTIME = 'ctime'¶
Timestamp is from the email’s file on the filesystem.
Attention
Only meaningful for sources storing one mail per file, like
maildirandeml.
- mail_deduplicate.mail.ADDRESS_HEADERS = frozenset({'bcc', 'cc', 'delivered-to', 'disposition-notification-to', 'envelope-to', 'from', 'original-recipient', 'reply-to', 'resent-bcc', 'resent-cc', 'resent-from', 'resent-reply-to', 'resent-sender', 'resent-to', 'return-path', 'sender', 'to', 'x-envelope-from', 'x-envelope-to', 'x-original-to'})¶
Headers that contain email addresses.
Hint
Headers from which quotes should be discarded, so
"Bob" <bob@example.com>hashes to the same thing asBob <bob@example.com>.Attention
These IDs should be kept lower-case, because they are compared to the IDs provided to the
-h/--hash-headeroption, carried by thehash_headersentry of the configuration.
- mail_deduplicate.mail.HEADER_LINE = re.compile(b'\\A(From |[!-9;-~]*:|[\\t ])')¶
A line of the header block, as the parser reads one: an envelope line, a header or a folded continuation.
The parser ends the headers on the first blank line, or on the first line that is none of these when the blank line is missing.
split_raw()follows the same rule.
- mail_deduplicate.mail.LINE = re.compile(b'[^\\r\\n]*[\\r\\n]|[^\\r\\n]+\\Z')¶
One line of a mail with LF line endings, with its line break when it has one.
A bare CR also ends a line, as it does for the parser.
- class mail_deduplicate.mail.DedupMailMixin(message=None)[source]¶
Bases:
MessageMessage with deduplication-specific properties and utilities.
Extends standard library’s mailbox.Message, and shouldn’t be used directly, but composed with
mailbox.Messagesub-classes.Initialize a Message instance.
- CONTENT_CACHES: tuple[str, ...] = ('body_lines', 'canonical_headers', 'hash_decoded_body', 'hash_normalized_body', 'hash_raw_body')¶
Memoized properties derived from the message content.
They are only needed while computing hashes, and are dropped by
dehydrate()alongside the parsed message itself.
- resolve_path: Callable[[Mailbox, str], str]¶
Derives the real filesystem location of a mail from its box.
Set per box format by
make_dedup_mail(), as folder-based boxes give each mail its own file while file-based ones pack them all into the box’s single file.
- defects: list = ()¶
Fallback for the parsing defects dropped by
dehydrate().An empty tuple rather than a list, so it can be shared by every dehydrated mail instead of costing one throw-away empty list each: reads still work, and the appends only the parser performs fail loudly on a mail that has no message.
- PARSED_MESSAGE_ATTRS: tuple[str, ...] = ('_headers', '_payload', '_unixfrom', 'preamble', 'epilogue', 'defects')¶
Attributes carrying the parsed message, as populated by
email.parser.They hold the bulk of a mail’s memory footprint, and are the ones dropped by
dehydrate()and restored byhydrate().
- box: Mailbox | None¶
The box this message was read from, kept to re-fetch its content on demand after dehydration.
- property path: str¶
Real filesystem location of the mail.
Returns the individual mail’s file for folder-based box types (
maildir& co.), but returns the whole box path for file-based boxes (mbox& co.). Used by regexp-based selection strategies and to render the mail’s repr.Derived on access from the box rather than stored, so a retained mail does not carry a copy of its own absolute path for the whole run. Raises
AttributeErrorbefore the box metadata is attached, so getattr(mail, “path”, None) still reads as absent.
- add_box_metadata(box, mail_id)[source]¶
Attach to the mail some metadata derived from its parent box.
Called right after the
__init__()constructor.This allows the mail to carry its own information on its origin box and index.
- Return type:
- dehydrate()[source]¶
Reduce the mail to the lightweight metadata needed by the next steps.
Memoizes the scalar properties consumed by selection strategies (timestamp, and size when the decoded body is at hand), then drops the parsed message and every cached copy of its content. This cuts the resident footprint of a retained mail from the full size of its message to a few hundred bytes, whatever the number of mails processed. See: https://github.com/kdeldycke/mail-deduplicate/issues/761
No-op if the mail is already dehydrated. The dropped content is re-read from the source box by
hydrate()when a later step needs it again.- Return type:
- hydrate()[source]¶
Restore the full parsed message dropped by
dehydrate().Re-reads the mail from its source box, through the same parsing path that produced it in the first place, so the restored content is identical. No-op if the mail still carries its parsed message.
- Return type:
- raw_bytes()[source]¶
Read the bytes of the mail again, from its own file or from its box.
The parser consumed them without keeping them. A mail a worker opened by path is read from that file. A mail of the main process is read from its box, which hands it out without the envelope line and the state its format keeps beside each mail.
- Return type:
- hydrated()[source]¶
Borrow the full parsed message for the duration of the block.
Restores the message on the way in and releases it on the way out, so a step needing the whole mail after the hashing one keeps its content resident only while it uses it, and memory stays flat across a loop of mails.
- Return type:
- property parsed_date: float | None[source]¶
Parse the mail’s date header into float timestamp.
Returns
Noneif the mail has no valid date header, or one whose year no timestamp can hold.Self-hydrating: re-reads the message from its box if it was dehydrated.
- property timestamp: float | None[source]¶
Compute the normalized canonical timestamp of the mail.
Sourced from the message’s
Dateheader by default. In the case ofmaildir, can be sourced from the email’s file from the filesystem.Warning
ctimedoes not refer to creation time on POSIX systems, but rather the time of the last metadata change.Todo
Investigate what mailbox.MaildirMessage.get_date() does and if we can use it.
- property size: int[source]¶
Returns the canonical size of the mail, in bytes.
The size is the byte length, in UTF-8, of the decoded body: the payload of the mail stripped of its headers, its transfer encodings undone and its text decoded, never the size of the mail on disk. Copies of a mail that differ in their transfer encoding alone measure the same, and
--size-thresholdcompares them in the unit it names.Todo
Allow customization of the way the size is computed, by getting the file size instead, for example with
os.path.getsize(mail_file).
- property body_lines: list[str][source]¶
Return the lines of the decoded body of the message.
Each text part has its transfer encoding undone and its text decoded with its charset, so copies of a mail that differ in their encoding alone read the same. A part that is not text is kept as the mail carries it. The preamble and epilogue of a multipart are read as UTF-8.
Self-hydrating: re-reads the message from its box if it was dehydrated.
- undo_surrogateescape(text, errors='replace')[source]¶
Decode as UTF-8 the non-ASCII bytes that parsing left as lone surrogates.
A mail parsed from bytes keeps each non-ASCII byte as a lone surrogate, and encoding such a string to UTF-8 raises
UnicodeEncodeError.get_payload()undoes this for each part, with the charset of the part. The preamble and the epilogue have no charset, so they go through here instead. Bytes that are not valid UTF-8 becomeU+FFFD, asget_payload()does with its charset, unlesserrorsnames another way out, likebackslashreplace.Encoding with
utf-8instead ofasciikeeps the non-ASCII characters of a mail parsed from a string, whichasciirejects.- Return type:
- hash_key()[source]¶
Returns the canonical hash of a mail.
Under
--raw-headers, the hash is that of the header block, asraw_headers()reads it: every header takes part, so no minimal number of them applies.Caution
This method hasn’t been made explicitly into a cached property in order to reduce the overall memory footprint.
- Return type:
- split_raw()[source]¶
The bytes of the mail as the box holds them, cut into headers and body.
Line endings are reduced to LF, as
add_mail()writes them: every box format rewrites them to the separator of the platform, and the mbox reader reduces them on Windows, so a mail would otherwise never hash like its own export, and a hash would depend on the platform. Nothing else is interpreted. A header a server or a client added, an mbox>Fromquote and a transfer encoding all count.The header block ends with the first blank line, which it keeps. When that blank line is missing, it ends before the first line that is neither a header nor a continuation, where the parser starts the body too. A mail of headers alone has an empty body. The two halves always join back into the whole mail, so
--raw-headerswith--hash-body rawhashes every byte.
- property hash_raw_body: str[source]¶
Returns the hash of the body bytes, as the box holds them.
Nothing but the line endings is interpreted, see
raw_body(): a transfer encoding, a charset, a bare CR and an mbox>Fromquote all count, wherehash_decoded_bodyreads through the first two.
- property hash_decoded_body: str[source]¶
Returns the hash of the decoded body of a mail, line by line.
- property hash_normalized_body: str[source]¶
Returns the hash of the decoded body of a mail, stripped of whitespace.
- property canonical_headers: tuple[tuple[str, str], ...][source]¶
Returns the canonical header names and values, ready for hashing.
Under
--raw-headers, it returns every header instead, in its order and unnormalized, for--hash-onlyto show. The hash itself reads the bytes of the header block: seeraw_headers().Self-hydrating: re-reads the message from its box if it was dehydrated.
- pretty_canonical_headers()[source]¶
Renders a table of headers names and values used to produce the mail’s hash.
Caution
This method hasn’t been explicitly made into a cached property in order to reduce the overall memory footprint.
Returns a string ready to be printed.
- Return type:
- serialized_headers()[source]¶
Serialize the canonical headers into a single string ready to be hashed.
At this point we should have an absolute minimum of headers.
Caution
This method hasn’t been explicitly made into a cached property in order to reduce the overall memory footprint.
- Return type:
- header_text(value)[source]¶
The text of one header value, as the mail holds it.
A header holding raw 8-bit bytes is read as those bytes: decoded as UTF-8 where they are valid UTF-8, and escaped byte by byte elsewhere.
get()andget_all()hand such a header over as aHeader, which string functions likeemail.utils.parsedate_tz()reject. Its text also replaces every such byte withU+FFFD, under which two headers differing in those bytes alone read, and hash, alike.- Return type:
- header_values(header_id)[source]¶
Returns the text of each occurrence of a header, in the order of the mail.
Each value reads as
header_text()reads it.
- normalized_header_values(header_id)[source]¶
Returns all normalized values of a header.
Values are cleaned-up into their canonical form, from the text
header_values()reads.
- normalize_subject(subject)[source]¶
Strip
Re:/Fwd:and[list-name]prefixes fromSubject.This cleans up prefixes automatically added by mailing list software, since the mail could have been
CC’d to multiple lists, in which case it will receive a different prefix for each.- Return type:
- normalize_content_type(value)[source]¶
Normalize
Content-Typeby stripping parameters.Removes everything after the semicolon, keeping only the MIME type. E.g.,
text/plain; charset=utf-8becomestext/plain.Apparently list servers actually munge
Content-Typee.g. by stripping the quotes fromcharset="us-ascii". Section 5.1 of RFC2045 says that either form is valid (and they are equivalent).Additionally, with multipart/mixed, boundary delimiters can vary by recipient. We need to allow for duplicates coming from multiple recipients, since for example you could be signed up to the same list twice with different addresses. Or maybe someone bounces you a load of mail some of which is from a mailing list you’re both subscribed to - then it’s still useful to be able to eliminate duplicates.
- Return type:
- normalize_date(value)[source]¶
Normalize
DatetoYYYY-MM-DDformat.Date timestamps can differ by seconds or hours for various reasons, so only the date is honoured, normalized to the UTC timezone.
Todo
Revisit the day-level granularity, and whether the time of the day should take part in the hash.
- Return type:
- normalize_address_header(value)[source]¶
Normalize address headers by removing quotes and collapsing whitespace.
E.g.,
"Bob" <bob@example.com>becomesBob <bob@example.com>.Remove quotes in any headers that contain addresses to ensure a quoted name is hashed to the same value as an unquoted one.
Danger
This may not be the cleanest way to normalize email addresses. E.g.
"Robert \"Bob\""becomesRobert \Bob\, but this shouldn’t matter for hashing purposes as we’re just trying to get a good heuristic. Refs: #846 and #847.- Return type:
- normalize_message_id(value)[source]¶
Normalize Message-ID header by stripping angle brackets.
E.g.,
<unique-id@example.com>becomesunique-id@example.com.- Return type:
- strip_angle_brackets(value)[source]¶
Strip angle brackets from a value if it’s a single bracketed item.
Only strips if the value matches
<something>with no commas.Note
Sometimes
email.parserstrips the<>brackets from aTo:header which has a single address. I have seen this happen for only one mail in a duplicate pair. I’m not sure why (presumably the parser usesemail.utils.unquotesomewhere in its code path which was only triggered by that mail and not its sister mail), but to be safe, we should always strip the<>brackets to avoid this difference preventing duplicate detection.- Return type:
mail_deduplicate.mail_box module¶
Utilities to read and write mail boxes in various formats.
Based on Python’s standard library mailbox module.
- exception mail_deduplicate.mail_box.MailSourceError(message)[source]¶
Bases:
ClickExceptionA mail source that cannot be used as given.
Raised for a problem the user can fix: a folder with no structure mdedup recognizes, a box another program locked, a source given twice. Click prints it as a one-line
Error:, with no traceback and no invitation to report a bug.
- mail_deduplicate.mail_box.maildir_subpath(box, key)[source]¶
Path of a
maildirmail below its box, read from the table of contents.A maildir key drops the
:2,<flags>suffix the file name carries, so the name cannot be rebuilt from the key alone. The box refreshed its table of contents to hand out the mail in the first place, so it is only re-validated on a miss.- Return type:
- mail_deduplicate.mail_box.maildir_mail_path(box, key)[source]¶
Location of a
maildirmail, read from the box’s table of contents.- Return type:
- mail_deduplicate.mail_box.keyed_mail_path(box, key)[source]¶
Location of an
MHoremlmail, whose file is named after its key.- Return type:
- mail_deduplicate.mail_box.box_file_path(box, key)[source]¶
Location of a mail from a file-based box: the box’s own single file.
Every mail of an
mbox,babylormmdfbox is packed into it, so they all share this path and are told apart by their mail ID.- Return type:
- mail_deduplicate.mail_box.iter_mail_ids(box)[source]¶
Yields the key of every mail held by a box.
Maildir.iterkeys()confirms that each key still resolves to a file, onestatper mail, on top of the directory listing it has just built. Every caller here goes on to stat or open that same file anyway, and copes with its disappearance, so the check is paid for twice and needed once: reading the refreshed table of contents directly skips it.The other formats list their mails without that extra round, and are left to their own iterator.
- mail_deduplicate.mail_box.resolve_mail_path(box, key)[source]¶
Location of a mail in its box, without instantiating the mail.
Lets a caller reach a mail’s file before deciding to read it, which is how the hash cache checks whether a mail changed without paying for its parsing.
- Return type:
- mail_deduplicate.mail_box.make_dedup_mail(name, base, path_resolver)[source]¶
Create a DedupMail class for a mailbox message type.
Deriving a mail’s own location from its box is format-specific, so the resolver is baked into the class here instead of being branched on at runtime.
- Return type:
- class mail_deduplicate.mail_box.MaildirDedupMail(message=None)¶
Bases:
DedupMailMixin,MaildirMessageInitialize a MaildirMessage instance.
- class mail_deduplicate.mail_box.mboxDedupMail(message=None)¶
Bases:
DedupMailMixin,mboxMessageInitialize an mboxMMDFMessage instance.
- class mail_deduplicate.mail_box.MHDedupMail(message=None)¶
Bases:
DedupMailMixin,MHMessageInitialize an MHMessage instance.
- class mail_deduplicate.mail_box.BabylDedupMail(message=None)¶
Bases:
DedupMailMixin,BabylMessageInitialize a BabylMessage instance.
- class mail_deduplicate.mail_box.MMDFDedupMail(message=None)¶
Bases:
DedupMailMixin,MMDFMessageInitialize an mboxMMDFMessage instance.
- class mail_deduplicate.mail_box.EMLDedupMail(message=None)¶
Bases:
DedupMailMixin,MessageInitialize a Message instance.
- class mail_deduplicate.mail_box.EML(dirname, factory=None, create=True)[source]¶
Bases:
MailboxA folder of loose
.emlfiles, walked recursively.Supports mail archives exported as individual RFC 5322 files, one mail per file, as produced by Outlook PST/OST conversion tools for instance. See: https://github.com/kdeldycke/mail-deduplicate/issues/760
Keys are the paths of the mail files, relative to the folder’s root. Files without the
.emlextension (case-insensitive) are ignored, as well as hidden files and directories.Follows the interface of Python’s mailbox.Mailbox. Like
maildir, the one-file-per-mail storage needs no locking.Initialize a Mailbox instance.
- class mail_deduplicate.mail_box.BoxStructure(*values)[source]¶
Bases:
EnumBox structures can be file-based or folder-based.
- FOLDER = 1¶
- FILE = 2¶
- class mail_deduplicate.mail_box.BoxFormat(*values)[source]¶
Bases:
EnumIDs of all the supported box formats and their metadata.
Each entry is associated to:
their original base class,
the structure they implement (file-based or folder-based),
the custom message factory class to use.
From these, we can derive the proper constructor with our own custom
DedupMailfactory.Todo
Add support for other mailbox formats and sources, like Gmail accounts, IMAP servers, etc.
- MAILDIR = (<class 'mailbox.Maildir'>, BoxStructure.FOLDER, <class 'mail_deduplicate.mail_box.MaildirDedupMail'>)¶
- MBOX = (<class 'mailbox.mbox'>, BoxStructure.FILE, <class 'mail_deduplicate.mail_box.mboxDedupMail'>)¶
- MH = (<class 'mailbox.MH'>, BoxStructure.FOLDER, <class 'mail_deduplicate.mail_box.MHDedupMail'>)¶
- BABYL = (<class 'mailbox.Babyl'>, BoxStructure.FILE, <class 'mail_deduplicate.mail_box.BabylDedupMail'>)¶
- MMDF = (<class 'mailbox.MMDF'>, BoxStructure.FILE, <class 'mail_deduplicate.mail_box.MMDFDedupMail'>)¶
- EML = (<class 'mail_deduplicate.mail_box.EML'>, BoxStructure.FOLDER, <class 'mail_deduplicate.mail_box.EMLDedupMail'>)¶
- property constructor¶
Return a constructor for this box format with our custom message factory.
- mail_deduplicate.mail_box.FOLDER_FORMATS = (BoxFormat.MAILDIR, BoxFormat.MH, BoxFormat.EML)¶
Box formats implementing a folder-based structure.
Is a tuple to keep natural order defined by
BoxFormat.
- mail_deduplicate.mail_box.FILE_FORMATS = (BoxFormat.MBOX, BoxFormat.BABYL, BoxFormat.MMDF)¶
Box formats implementing a file-based structure.
Is a tuple to keep natural order defined by
BoxFormat.
- mail_deduplicate.mail_box.FOLDER_FORMAT_CLASSES = (<class 'mailbox.Maildir'>, <class 'mailbox.MH'>, <class 'mail_deduplicate.mail_box.EML'>)¶
Base classes of folder-based box formats, as a tuple ready for
isinstance.
- mail_deduplicate.mail_box.MAILDIR_SUBDIRS = frozenset({'cur', 'new', 'tmp'})¶
List of required sub-folders defining a properly structured maildir.
- mail_deduplicate.mail_box.MBOX_FAMILY = (<class 'mailbox.mbox'>, <class 'mailbox.MMDF'>)¶
Formats keeping an envelope
Fromline before each mail.The standard library reads and writes both with the same code, so the line travels from one to the other.
- mail_deduplicate.mail_box.is_maildir(path)[source]¶
Returns
Truewhen the path holds all the sub-directories of a maildir.- Return type:
- mail_deduplicate.mail_box.contains_maildir(path)[source]¶
Returns
Truewhen the path is a maildir or holds one at any depth.Allows the discovery of nested maildir folders stored as plain directories, as produced by isync/mbsync’s Verbatim naming style. See: https://github.com/kdeldycke/mail-deduplicate/issues/973
Dot-prefixed directories are ignored, as they are covered by the
Maildir++folder convention. The mail-holding sub-directories of maildirs are not explored either.- Return type:
- mail_deduplicate.mail_box.contains_eml(path)[source]¶
Returns
Truewhen the path holds at least one.emlfile, at any depth.Hidden files and directories are ignored, and the extension is matched case-insensitively, mirroring the walk of
EMLboxes.- Return type:
- mail_deduplicate.mail_box.autodetect_box_type(path)[source]¶
Auto-detect the format of the mailbox located at the provided path.
If the path is a file, then it is considered as an
mbox. Else, if the provided path is a folder and features theMAILDIR_SUBDIRSsub-directories, or holds nested maildir folders at any depth, it is parsed as amaildir. A folder holding loose.emlfiles instead is parsed as anemlsource.Todo
Future finer autodetection heuristics should be implemented here. Some ideas:
single mail from a
maildirplain text mail content
other mailbox formats supported in Python’s standard library:
MHBabylMMDF
- Return type:
- mail_deduplicate.mail_box.open_box(path, box_format=None, force_unlock=False)[source]¶
Open a mail box.
Returns a list of boxes, one per sub-folder. All are locked, ready for operations.
If
box_formatis provided, forces the opening of the box in the specified format. Else, defaults to autodetection.
- mail_deduplicate.mail_box.lock_box(box, force_unlock)[source]¶
Lock an opened box and allows for forced unlocking.
Returns the locked box. Raises
MailSourceErrorif another lock holds the box andforce_unlockis not set.- Return type:
- mail_deduplicate.mail_box.release_box(box)[source]¶
Unlock a box and close its file, without writing its pending changes.
Mailbox.close()first writes the pending changes of a single-file box, which rewrites the whole file when a mail was removed. A run cut short by an error or an interrupt calls this instead: its single-file boxes stay as the run found them, and an interrupt does not start a rewrite.Unlike
MH.close(), this also closes the.mh_sequencesfile that a refusedMH.lock()leaves open.- Return type:
- mail_deduplicate.mail_box.open_subfolders(box, force_unlock)[source]¶
Browse recursively the subfolder tree of a box.
Returns a list of opened and locked boxes, each for one subfolder. If a subfolder cannot be locked, because another program holds it for instance, the boxes locked before it are unlocked again and the error is raised: the next run then needs no
--force-unlockfor them.Skips box types not supporting subfolders. For
maildir, both theMaildir++convention (dot-prefixed folders) and Verbatim-style layouts (nested plain directories, each a maildir of its own) are browsed. A directory without the maildir structure only acts as a container of nested folders and carries no mail of its own.
- mail_deduplicate.mail_box.carry_maildir_metadata(source, key, target, new_key)[source]¶
Give a copied
maildirmail the folder, flags and time of its original.A maildir keeps the state of a mail outside of its content: the
neworcurfolder says whether a client saw it, the:2,suffix of its file name holds its flags, and the modification time of the file is its delivery time, which IMAP servers like Dovecot serve as the internal date of the mail.Maildir.add()takes them from aMaildirMessagealone, so a mail added as bytes lands innewwith no flags and the time of the run: it is renamed and re-dated afterwards.- Return type:
- mail_deduplicate.mail_box.carry_mh_sequences(source, key, target, new_key)[source]¶
Enroll a copied
MHmail in the sequences of its original.MH keeps the state of a mail (
unseen,replied,flagged) in the.mh_sequencesfile of its folder, by the number of the mail.MH.add()takes them from anMHMessagealone, so they are written here for a mail added as bytes, the wayMH.add()writes them.- Return type:
- mail_deduplicate.mail_box.add_mail(box, mail)[source]¶
Add a mail to a box, as the bytes its source box holds.
Every box format writes a mail handed over as a message with
BytesGenerator, from the message parsed out of it. The generator does not write the mail back: it rebuilds it. It redraws each multipart boundary, closes one left open, turns a bare\rinto a line break, and cannot write at all a multipart part parsed with no subpart, which python/cpython#136686 tracks. Deduplicating a mail is no reason to repair it, so every mail goes in as bytes, which each format writes as they are. See: https://github.com/kdeldycke/mail-deduplicate/issues/1090Line endings are the one conversion left. They are reduced to LF before the write, as every box format but
emlwrites\nas the line separator of the platform, and would double the CR of a CRLF ending on Windows. An mbox or MMDF box also quotes the body lines starting withFrom, as its format requires.The bytes do not include what a format keeps beside each mail: the envelope
Fromline of an mbox or MMDF mail, the folder, flags and modification time of a maildir mail, the sequences of an MH mail, the labels of a Babyl mail. The first three travel with the mail into a box of the same format, and the envelope line also travels between mbox and MMDF, which keep it alike. A box of another format has no place for them, and nothing is translated: a maildir mail copied into an mbox gets noStatusheader. Babyl labels are not carried, as the standard library takes them from aBabylMessagealone.- Return type:
mail_deduplicate.strategy module¶
Strategy definitions.
- mail_deduplicate.strategy.log_selection(message_template)[source]¶
Decorator to log selection criteria.
- mail_deduplicate.strategy.select_older(duplicates)[source]¶
Select all older duplicates.
Discards the newests, i.e. the subset sharing the most recent timestamp.
- Return type:
- mail_deduplicate.strategy.select_oldest(duplicates)[source]¶
Select all the oldest duplicates.
Discards the newers, i.e. all mail of the duplicate set but those sharing the oldest timestamp.
- Return type:
- mail_deduplicate.strategy.select_newer(duplicates)[source]¶
Select all newer duplicates.
Discards the oldest, i.e. the subset sharing the most ancient timestamp.
- Return type:
- mail_deduplicate.strategy.select_newest(duplicates)[source]¶
Select all the newest duplicates.
Discards the olders, i.e. all mail of the duplicate set but those sharing the newest timestamp.
- Return type:
- mail_deduplicate.strategy.select_smaller(duplicates)[source]¶
Select all smaller duplicates.
Discards the biggests, i.e. the subset sharing the biggest size.
- Return type:
- mail_deduplicate.strategy.select_smallest(duplicates)[source]¶
Select all the smallest duplicates.
Discards the biggers. i.e. all mail of the duplicate set but those sharing the smallest size.
- Return type:
- mail_deduplicate.strategy.select_bigger(duplicates)[source]¶
Select all bigger duplicates.
Discards the smallests, i.e. the subset sharing the smallest size.
- Return type:
- mail_deduplicate.strategy.select_biggest(duplicates)[source]¶
Select all the biggest duplicates.
Discards the smallers, i.e. all mail of the duplicate set but those sharing the biggest size.
- Return type:
- mail_deduplicate.strategy.select_matching_path(duplicates)[source]¶
Select all duplicates whose file path matches the –regexp expression.
- Return type:
- mail_deduplicate.strategy.select_non_matching_path(duplicates)[source]¶
Select all duplicates whose file path doesn’t match the –regexp expression.
- Return type:
- mail_deduplicate.strategy.first_by_path(duplicates)[source]¶
The mail of a set that
select_one()keeps andselect_all_but_one()drops.Neither strategy compares the mails, so the order of their paths decides: the order the action trail lists them in. Both strategies then rule on the same mail at every run. A dry run names the copy that the real run acts on, and a
--jobsworker picks the copy that the main process would.- Return type:
- mail_deduplicate.strategy.select_one(duplicates)[source]¶
Select the duplicate whose path sorts first, and discard all others.
- Return type:
- mail_deduplicate.strategy.select_all_but_one(duplicates)[source]¶
Discard the duplicate whose path sorts first, and select all others.
- Return type:
- class mail_deduplicate.strategy.Strategy(*values)[source]¶
Bases:
EnumSelection strategies to apply on a set of duplicate mails.
Each strategy in the
Enumpoints to the function implementing the selection logic, by way of thefunctionproperty.Strategies whose member value is a string are simply aliases to other strategies, pointing to the name of the function implementing the logic. The other members have integer values, to indicate their function ID is to be derived from the member name. This arrangement lets each member exist on its own instead of being hidden by the aliasing mechanism of
Enum.Aliases are great usability features to represent inverse operations. They help users reason about the selection operators in whichever direction matches their mental model.
Note
True
Enumaliases (DISCARD_NEWEST = SELECT_OLDER) were considered and turned down. An alias is then the very same object as its canonical member, so it cannot be told apart at runtime, and three properties go away:str()stops round-tripping the spelling the user typed: a run started with--strategy=discard-newestwould reportselect-olderin its logs.PATH_STRATEGIESincli.pycollapses from four members to two, so the--regexphelp line and theAnyValueInerror message would name only half the strategies that require it.tests/test_strategy.pyfeedsf"--strategy={strategy_id}", so pairs like[SELECT_OLDER, DISCARD_NEWEST]would stringify identically and the alias spellings would no longer be exercised end to end.
Todo
Revisit the aliasing arrangement once the Python floor reaches 3.13, whose
Enum._add_alias_()accepts non-identifier names, soSELECT_OLDER._add_alias_("discard-newest")puts the CLI spelling straight into__members__, whereEnumChoice(Strategy, choice_source="name", show_aliases=True)picks it up.EnumChoicealso grew atransformargument (click-extra 8.9.0) to kebab-case the raw identifiers thatshow_aliasesotherwise yields. Neither addresses the three losses listed in the note above, which is why the current arrangement stands.- SELECT_OLDER = 1¶
- SELECT_OLDEST = 2¶
- SELECT_NEWER = 3¶
- SELECT_NEWEST = 4¶
- DISCARD_NEWEST = 'select_older'¶
- DISCARD_NEWER = 'select_oldest'¶
- DISCARD_OLDEST = 'select_newer'¶
- DISCARD_OLDER = 'select_newest'¶
- SELECT_SMALLER = 5¶
- SELECT_SMALLEST = 6¶
- SELECT_BIGGER = 7¶
- SELECT_BIGGEST = 8¶
- DISCARD_BIGGEST = 'select_smaller'¶
- DISCARD_BIGGER = 'select_smallest'¶
- DISCARD_SMALLEST = 'select_bigger'¶
- DISCARD_SMALLER = 'select_biggest'¶
- SELECT_MATCHING_PATH = 9¶
- SELECT_NON_MATCHING_PATH = 10¶
- DISCARD_NON_MATCHING_PATH = 'select_matching_path'¶
- DISCARD_MATCHING_PATH = 'select_non_matching_path'¶
- SELECT_ONE = 11¶
- SELECT_ALL_BUT_ONE = 12¶
- DISCARD_ALL_BUT_ONE = 'select_one'¶
- DISCARD_ONE = 'select_all_but_one'¶
- property function: Callable[[DuplicateSet], set[DedupMailMixin]]¶
The selection function this member stands for.
Alias members carry the function’s name as their value; canonical members derive it from their own name.