Compare commits

...

187 commits

Author SHA1 Message Date
the-djmaze
870df2d861 Resolve #1990 2026-10-24 16:24:28 +02:00
the-djmaze
343ef172d1 Resolve #2008 2026-10-24 14:52:36 +02:00
the-djmaze
bca01170a4 Resolve #2018 2026-10-24 14:48:02 +02:00
the-djmaze
ddf28c299c Require PHP 8.2 2026-10-24 14:12:28 +02:00
the-djmaze
b5c1be34c2 Don't inflate http response by default 2026-10-23 16:45:02 +02:00
Maarten
0204ca48ee
Merge pull request #2088 from JPRauwolf/patch-1
remove opcache installation from docker build
2026-10-23 15:15:59 +02:00
Maarten
0358fdf971
Merge pull request #2087 from FathiBenNasr/fix/header-unfold-encoded-word
MailSo: keep the folding space before an encoded word that follows a plain word
2026-10-23 15:09:28 +02:00
the-djmaze
a0e093d527 Several security advisories for NPM packages break the JS compiler.
Don't run `npm audit fix`!!!
2026-10-23 12:58:16 +02:00
the-djmaze
19de853729 PHP 8.5 has opcache built in 2026-10-23 11:17:43 +02:00
Jan Philipp Rauwolf
66725386b0
removed opcache installation from docker build
with php 8.5 opcache comes bundled, so the installation fails.
just removing the dependency fixed the build
2026-10-23 00:06:25 +02:00
the-djmaze
e1e94c815d The backtick (`) operator is deprecated, use shell_exec() instead 2026-10-22 23:02:00 +02:00
Fathi Ben Nasr
7c432e392b MailSo: a folded plain-text line no longer leaves two spaces
Unfolding appended a newline before the continuation line, which kept its
own leading whitespace; Header turns the newline into a space, so every
fold became two spaces ("et  checklist"), carried into reply subjects.
The test now compares exactly.
2026-10-22 20:55:38 +00:00
Fathi Ben Nasr
37fcdbe534 MailSo: keep the folding space before an encoded word that follows a plain word
HeaderCollection::Parse() trimmed the folding whitespace in front of every
continuation line starting with an encoded word. RFC 2047 6.2 ignores only
whitespace BETWEEN two adjacent encoded words; after a plain word, RFC 5322
unfolding keeps it. Symfony Mailer folds subjects exactly there, so
"de vos\r\n =?utf-8?Q?=C3=A9quipements?=" was displayed "voséquipements".
The message list (IMAP ENVELOPE) was fine; the opened message was not.

Test: php test/header-unfold.php (fails before the fix).
2026-10-22 20:55:38 +00:00
the-djmaze
c9f7d27551 Proxy requires mainaccount 2026-10-22 22:29:45 +02:00
the-djmaze
843b69bfa8 Send Accept-Encoding header in integrity test? 2026-10-22 22:29:04 +02:00
the-djmaze
61ebcb6ed4 CSP block legacy HTML element types such as <object>, <embed>, and <applet> on Raw actions 2026-10-22 18:04:40 +02:00
Maarten
ceb9f68ded
Merge pull request #1922 from leojonathanoh/fix/docker-ensure-nginx-does-not-listen-on-ipv6-in-ipv4-only-environments
Fix (docker): Ensure nginx does not listen on ipv6 in ipv4-only environments
2026-10-22 16:11:58 +02:00
Maarten
d7e7250b16
Merge pull request #2060 from FathiBenNasr/fix/carddav-sync-empty-set-guard
AddressBook: do not let an empty side wipe the other during sync
2026-10-22 16:09:56 +02:00
Maarten
0a8e37ccc8
Merge pull request #2070 from FathiBenNasr/feature/ldap-suggestions-multiple-base-dns
ldap-contacts-suggestions: search several base DNs
2026-10-22 16:07:51 +02:00
Maarten
2cff632b2d
Merge pull request #2021 from kristofer84/login-o365-bugfix
Corrected OAuth2 login flow and added better error handling
2026-10-22 14:15:28 +02:00
the-djmaze
2c658b7c31 Fix year for #1882 2026-10-22 14:12:26 +02:00
Maarten
4997853b78
Merge pull request #1882 from ZephOne/also-use-ldap-login-mapping-for-smtp-user
Also use ldap login mapping for smtp user
2026-10-22 14:10:42 +02:00
Maarten
6fba843d15
Merge pull request #2011 from juschleg/patch-1
Update SSLContext.php: fixed typo in line 42, to reference correct class
2026-10-22 14:06:53 +02:00
Maarten
3659552fac
Merge pull request #2035 from alterative-editions/plugin-login-http
Add HTTP based SSO plugin for Apache using PHP_AUTH_USER/PHP_AUTH_PW
2026-10-22 13:54:05 +02:00
the-djmaze
3432795930 Fix formatting and bump version for #2019 2026-10-22 13:50:36 +02:00
Maarten
fb4a247916
Merge pull request #2019 from oGrzegorZo/fix-search-filters-keyword-crash
Fix Search Filters plugin: crash on keyword filters
2026-10-22 13:44:56 +02:00
Maarten
a174bf7ba0
Merge pull request #1879 from tribut/smime-intermediate-certs
Add intermediate certificates for S/MIME signing
2026-10-22 13:41:20 +02:00
Maarten
77be04d427
Merge pull request #1863 from SergeyMosin/pgp-improvements
PGP improvements
2026-10-22 13:39:45 +02:00
Maarten
cf185b9ab5
Merge pull request #2066 from Ron-RONZZ-org/feature/admin-trusted-header-auth
Admin panel: accept trusted proxy-auth header (X-NC-Admin) on the admin host
2026-10-22 13:37:52 +02:00
Maarten
30bb5c934d
Merge pull request #2069 from SebastianAlvarezGajardo/fix/messagelist-keep-page-and-search
Don't reset the message list to page 1 and clear the search on message.show
2026-10-22 13:23:57 +02:00
Maarten
b9397e6e20
Merge pull request #2007 from Murena-SAS/dev/fix-autologin
Fix(auth): Correct OIDC login by using SensitiveString object.
2026-10-22 13:18:56 +02:00
the-djmaze
fd6beedc0c Merge branch 'master' of github.com:the-djmaze/snappymail 2026-10-22 13:16:11 +02:00
the-djmaze
45b94153c1 Resolve #1906 2026-10-22 13:15:59 +02:00
Maarten
02e96a9339
Merge pull request #2012 from juschleg/patch-2
fixed typo in imapsync.php: was "RainLoop\API" instead of "RainLoop\Api"
2026-10-22 13:12:04 +02:00
the-djmaze
b3edb5fb0a Versionnumber for #2064 2026-10-22 13:10:09 +02:00
Maarten
58d553b1a2
Merge pull request #2064 from FathiBenNasr/fix/ics-viewer-attachments-observable
ics-viewer: unwrap attachments and widen the calendar part match
2026-10-22 13:07:14 +02:00
Maarten
2f26779f4a
Merge pull request #2061 from FathiBenNasr/feature/avatars-contact-photo
avatars: use the PHOTO stored on a matching contact
2026-10-22 13:01:15 +02:00
Maarten
9f0e6d946a
Merge pull request #2074 from FathiBenNasr/fix/getcontactbyemail-missing-columns
PdoAddressBook: GetContactByEmail() passed the wrong columns
2026-10-22 12:59:25 +02:00
Maarten
85b142f559
Merge pull request #2039 from gocowim/fix_autocrypt_compliance
FIX non-compliant Autocrypt Header
2026-10-22 12:50:26 +02:00
the-djmaze
1be5abd122 Version number for #2024 2026-10-22 12:48:32 +02:00
Maarten
a9ca71bae1
Merge pull request #2024 from bohwaz/fix-remote-login
Fix login-remote plugin password handling
2026-10-22 12:45:17 +02:00
Maarten
0805f71461
Merge pull request #2005 from LoredCast/patch-1
Update PostgreSQL DSN in Docker.md
2026-10-22 12:44:25 +02:00
Maarten
abb83e48b0
Merge pull request #1967 from PhilippMundhenk/master
Plugin proxy-auth: Update instructions for Dovecot 2.4, "check proxy" setting fix, troubleshooting hints
2026-10-22 12:36:44 +02:00
Maarten
f52249b77b
Merge pull request #1973 from HeySora/add-sqlite-to-docker
Add pdo_sqlite installation to Docker image
2026-10-22 12:32:48 +02:00
Maarten
1bbd7f635a
Merge pull request #2052 from alexgabi/patch-1
Update user.json
2026-10-22 12:30:13 +02:00
Maarten
8a8304e81a
Merge pull request #2075 from FathiBenNasr/fix/htmlutils-data-attributes
HtmlUtils: strip every data-* attribute, not just the first one
2026-10-22 12:28:44 +02:00
Maarten
e7a491b0f5
Merge pull request #2079 from MiDniGG/fix/autosave-duplicate-contacts
Fix duplicate contacts on autosave for addresses with uppercase letters
2026-10-22 12:18:12 +02:00
the-djmaze
6c9d3edc18 Docker upgrade to PHP 8.5 #2086 2026-10-22 12:13:49 +02:00
Kristofer Nilsson
5dca6d4f65 Change viewModelTemplateID check for account popups 2026-10-22 12:12:51 +02:00
Cursor Agent
2af0af173e Refactor O365 login to use server-side state and add account support
Co-authored-by: kristofer.john.nilsson <kristofer.john.nilsson@gmail.com>
2026-10-22 12:12:51 +02:00
Kristofer Nilsson
7fdc77ce5f Corrected OAuth2 login flow and added better error handling 2026-10-22 12:12:51 +02:00
Maarten
86dc155d1c
Merge branch 'master' into patch-1 2026-10-21 15:44:30 +02:00
Maarten
5de44c77c2
Merge pull request #2084 from sasogu/add-catalan-localization
Add Catalan localization
2026-10-21 15:42:25 +02:00
the-djmaze
dfc9420385 Sabre XML v4.1.0 2026-10-21 15:18:43 +02:00
the-djmaze
e66f325aa3 Sabre VObject v5.0.0 2026-10-21 15:18:17 +02:00
the-djmaze
aad651f2a1 Remove buggy Squire ImageResizer 2026-10-20 23:41:06 +02:00
the-djmaze
67a68915db Knockout use Reflection binding 2026-10-20 23:34:48 +02:00
the-djmaze
71287022ca Make Languages modal 10px wider 2026-10-20 23:33:40 +02:00
the-djmaze
3da27c3bc8 window.localStorage to localStorage 2026-10-20 23:33:07 +02:00
the-djmaze
020927cc56 Squire v2.4.9 2026-10-20 23:32:32 +02:00
the-djmaze
8667af700d Improved font-size handling to prevent issue #1082 2026-10-20 16:25:00 +02:00
Samuel Soriano
4e65e7ee84 Use the username label for login fields 2026-10-16 17:54:39 +02:00
Samuel Soriano
aca14765c9 Review Catalan terminology, grammar and action meanings 2026-10-16 17:53:07 +02:00
Samuel Soriano
be91e4eb80 Document the Catalan language selector preview 2026-10-16 16:20:42 +02:00
Samuel Soriano
24d7dee9b9 Add Catalan localization 2026-10-16 16:19:20 +02:00
Andreas Pries
6ef8a9a391 Fix duplicate contacts on autosave for addresses with uppercase letters
IncFrec() lowercases the existing addresses but compares them against the
new address in its original case, so an address with uppercase letters is
never found and a new contact is created on every send.

Fixes #1767
2026-09-29 22:48:07 +02:00
Fathi Ben Nasr
08ddc323f9 HtmlUtils: strip every data-* attribute, not just the first one
BuildHtml() iterates $oElement->attributes -- a live DOMNamedNodeMap --
and calls removeAttribute() on the attribute the foreach is currently on.
That ends the iteration: the loop sees no further attribute, so exactly
one data-* is removed per element however many it carries.

Measured on PHP 8.5.10 / libxml 2.12.5, data-* left behind:

  <p data-a>                          -> none
  <p data-a data-b>                   -> data-b
  <p data-a data-b data-c>            -> data-b, data-c
  <p data-a data-b data-c data-d>     -> data-b, data-c, data-d
  <p DATA-A data-b>                   -> data-b

The single call site is buildMessage() in RainLoop/Actions/Messages.php,
reached from both DoSaveMessage() and DoSendMessage(), as the docblock on
BuildHtml() says. So the attributes that survive go out in the sent
message and into the draft that is APPENDed and read back later. It is
not the display path: an incoming body is only decoded and charset
converted in MailSo\Mail\Message::fromFetchResponse(), and the browser
filters it with the allowlists in dev/Common/Html.js.

The fix collects the names first, then removes them.
2026-09-05 21:44:57 +00:00
Fathi Ben Nasr
279cc6406e PdoAddressBook: GetContactByEmail() passed the wrong columns
GetContactByEmail() runs

    SELECT DISTINCT id_contact FROM rainloop_ab_properties WHERE ...

and hands the statement to getContactsFromPDO(), which unconditionally reads
id_contact_str, changed, etag and jcard from every row. None of those are
selected, so every match raises three PHP warnings:

    Undefined array key "id_contact_str"
    Undefined array key "changed"
    Undefined array key "etag"

and the Contact that comes back carries IdContactStr '' with Changed and Etag
both 0 -- values a CardDAV sync would use.

The lookup itself still works: with no jcard column either, every row falls
into the follow-up properties query, so the contact data is right and only the
metadata is wrong. That is what makes it easy to miss.

It is not rare. The avatars plugin calls GetContactByEmail() for the sender of
a message, so a mailbox view triggers it repeatedly -- 23 warnings in a single
session on one of our installs.

Fix: look the contact up by id instead. GetContactByID() already selects the
columns getContactsFromPDO() expects, so the Contact comes back complete, and
the search query keeps doing what it is good at -- finding the id. The unused
$oContact and $iIdContact locals go with it.

Verified against a real PDO: the current query returns one column and the
reader warns on id_contact_str; the replacement returns all of them, warns
nothing, and yields the real IdContactStr and changed instead of '' and 0.

Note: this layer has no test suite upstream, so the verification is a
standalone reproduction rather than an added test.
2026-09-05 03:37:09 +00:00
Fathi Ben Nasr
3dd6c85c87 ldap-contacts-suggestions: search several base DNs
A directory rarely keeps everything worth suggesting in one branch: people
sit in a people branch, meeting rooms and other bookable resources in a
separate one. Searching a single subtree misses them, and widening the base
to the domain root drags every service and role account into the list.

base_dn now accepts several branches separated by '|', which cannot appear
unescaped in a DN — an existing single-branch value parses to a one-element
list and behaves exactly as before. A branch that fails to answer is logged
with its own DN and no longer discards the whole lookup.
2026-08-24 01:20:24 +00:00
SebaSalvaG
e4ecda0e61 Don't reset the message list to page 1 and clear the search on message.show
The `mailbox.message.show` handler rewrote the hash to `mailBox(sFolder)` --
page 1, no search -- even when the user was already in that folder.

Because the hash of an open message carries the message id and the new one does
not, the two differ, the route fires again, the message is shown again, and the
handler runs again: a loop. Every iteration aborts whatever message list request
was pending, so browsing to page 2 or running a search silently bounces back to
page 1 of the INBOX with an empty search box.

Only navigate when we are not already in that folder. Clicking a new-mail
notification from another folder still takes you to the INBOX; if the message is
not in the current page, the else branch already opens it in the preview pane
without touching the list.

The same unconditional setHash a few lines below, for non-INBOX folders, gets
the same guard.
2026-08-23 22:38:40 +02:00
Rong Zhou
640c567210 Admin panel: accept trusted proxy-auth header (X-NC-Admin) on the admin host
Allows gating the SnappyMail admin panel behind an external single sign-on
layer (e.g. a reverse-proxy auth_request validated against an OpenID Connect /
Nextcloud session) without embedding an OIDC client in SnappyMail. The header
is honored only when present AND the request Host matches admin_panel.host
(defense in depth), so it has no effect on regular webmail or on the panel
when admin_panel.host is unset.

Behavior is unchanged when the header is absent: the existing
admin_login/admin_password (+ optional admin_totp) check applies.

Contributed from Ron-RONZZ-org/ronzz-nextcloud (webmail/patches/).
2026-08-16 18:17:08 +02:00
Fathi Ben Nasr
8a2d8e86a5 ics-viewer: unwrap attachments and widen the calendar part match
The panel never appears on a real invitation.

msg.attachments is a ko.observableArray, so msg.attachments.find() is
undefined and throws inside the message subscription - the rest of the
handler, including the JSON-LD path, dies with it. The app itself always
writes attachments().

A scheduling mail also does not necessarily carry a text/calendar
attachment. Evolution and Exchange put the scheduling part inline in
multipart/alternative as text/calendar and repeat it as an
application/ics attachment, so such a message shows two .ics files and
the current test matches neither reliably. Match text/calendar,
application/ics, text/x-vcalendar or an .ics file name - which is what
the commented-out line above was reaching for.
2026-08-14 10:12:06 +00:00
Fathi Ben Nasr
6a7cbe070c avatars: find the photo when several contacts share an address 2026-08-13 02:19:03 +00:00
Fathi Ben Nasr
1a0c9872b2 AddressBook: do not let an empty side wipe the other during sync
Sync() treats an empty list on either side as authoritative:

* a local contact holding an etag but missing from the remote listing is
  deleted locally, so an empty or unparsed listing removes every
  previously synced contact;
* a local tombstone is deleted remotely, so a local store that is empty
  apart from tombstones prunes the server.

An empty list is far more often a fault than a real "delete everything":
a fresh or rebuilt local store, a listing that failed to parse, a stale
server-side index. On a server whose DAV index had lost its records the
listing came back empty and the first sync removed 7 local contacts that
existed on both sides moments earlier.

Skip the deletions in whichever direction the source list is empty and
let the existing import/export reconcile instead. Both guards log a
warning with the counts, so a skipped pass is visible rather than silent.
When both sides hold data the behaviour is unchanged.
2026-08-12 23:38:53 +00:00
Philipp Mundhenk
863f719da3 proxy-auth: remove check_proxy / proxy_ip settings
The "Check Proxy" option inspected the forwarded client IP returned
by Http::GetClientIp(true), which resolves to HTTP_X_FORWARDED_FOR
first and only falls back to REMOTE_ADDR. In any typical reverse-proxy
deployment that header carries the end-user's IP, not the proxy's, so
the option never actually validated that the request came from the
trusted proxy. It only constrained the set of end-user IPs allowed
to use SSO, which is not a meaningful guarantee on its own.

Drop the option, the paired proxy_ip subnet field, and the
ip_in_range helper. Document that access must be gated at the
network layer (firewall / docker network / bound interface) and
that the upstream proxy must strip any client-supplied value of
the configured remote-user header.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 10:44:36 +02:00
Philipp Mundhenk
68588287ef proxy-auth: document APP_SALT rotation troubleshooting
Add README note explaining that AUTHENTICATIONFAILED after a
container rebuild or data reset is typically caused by an
APP_SALT change invalidating the encrypted master credentials,
and that re-entering them in the admin UI restores access.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 09:57:22 +02:00
Philipp Mundhenk
60ee74c68a proxy-auth: fix check_proxy enforcement and persistence
The proxy IP check was never enforced: ServiceProxyAuth read config
key 'proxy_check' but the property is registered as 'check_proxy',
so the lookup always returned the default empty string and the
else-branch set $sProxyRequest = true unconditionally.

The BOOL property also had SetEncrypted(), which caused getDecrypted()
to call DecryptFromJSON() on a literal bool and fail with
"DecryptFromJSON() invalid $data", so the admin UI never reflected
the saved value. BOOLs are stored as plain bools in JSON; switch to
Get() and drop SetEncrypted() on the property.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 09:54:54 +02:00
Philipp Mundhenk
ed35c8e91e
Merge branch 'the-djmaze:master' into master 2026-05-17 09:47:52 +02:00
alexgabi
f757d58534
Update user.json
Rewieved and completed basque translation
2026-05-04 13:11:34 +02:00
the-djmaze
c154d23cfe Ommit Nextcloud "max-version" as it is optional 2026-03-11 17:56:44 +01:00
Till Wimmer
c1f1faca58 FIX non-compliant Autocrypt Header 2026-02-13 02:06:33 +01:00
Super User
dc0191aaf1 Add HTTP based SSO plugin for Apache using PHP_AUTH_USER/PHP_AUTH_PW 2026-02-09 19:47:30 +00:00
bohwaz
a8117a9d03 Fix login-remote plugin password handling 2026-01-06 00:04:41 +01:00
oGrzegorZo
f2648a54c0
Fix Search Filters plugin: crash on keyword filters
Fix Search Filters plugin: crash on keyword filters

- Added try/catch in searchMessages() to prevent fatal IMAP errors.  
- Correctly handle keyword filters ($label1, $label2) with multi-folder search.  
- Extracted applyActions() to handle fSeen/fFlag/fFolder consistently.  
- Ensures plugin no longer crashes when messages exist with a tag.
2025-12-16 14:19:30 +01:00
Jürgen Schlegelmilch
1dc26f11d2 fixed typo in imapsync.php: was "RainLoop\API" instead of "RainLoop\Api" 2025-11-19 23:10:15 +01:00
juschleg
c203d15b17
Update SSLContext.php: fixed typo in line 42, to reference correct class
The reference to class RainLoop\API is invalid, the author obviously meant class RainLoop\Api
2025-11-15 23:12:25 +01:00
Avinash Gusain
2c54ac9be3 Fix(auth): Correct OIDC login by using SensitiveString object. 2025-11-12 14:41:47 +05:30
Manuel
2c73a955e2
Update PostgreSQL DSN in Docker.md
docker compose file defines network as db not postgres
2025-11-09 20:00:39 +01:00
HeySora
658b97f5b4
Add pdo_sqlite installation to Docker image 2025-07-16 12:50:18 +02:00
the-djmaze
ba496919dd Catch Throwable for #1914 2025-06-16 17:35:11 +02:00
Maarten
24bb509b96
Merge pull request #1914 from sgoranov/fix/1913-datetime-overflow
Fix DateTimeHelper::ParseRFC2822DateString integer overflow on 32-bit PHP
2025-06-16 17:30:55 +02:00
Maarten
56409dcbb4
Merge branch 'master' into pgp-improvements 2025-06-16 17:28:39 +02:00
Maarten
b4a6068d5b
Merge pull request #1875 from bapt/fr
fr: add an space before the colon
2025-06-16 17:26:21 +02:00
Maarten
40cf2b950e
Merge pull request #1931 from TLTDevRu/fix-ru-lang
Fix ru lang
2025-06-16 17:23:27 +02:00
Maarten
7397a95a7c
Merge pull request #1941 from bober757/master
incorrect using of the preposition
2025-06-16 17:22:21 +02:00
the-djmaze
9dd7c021fd Small change for #1948 to make it relative to font size 2025-06-16 17:21:26 +02:00
Maarten
9f6d2e4cb7
Merge pull request #1948 from makoehr/compact-folder-list-for-snappymail
More compact view of folder list
2025-06-16 17:19:20 +02:00
Maarten
93e3c431ec
Merge pull request #1961 from Psychi1/Cp1252
Fix the Cp1252 encoding problem
2025-06-16 17:03:26 +02:00
Philipp Mundhenk
a9b89fd7b3
Update instructions for Dovecot 2.4 2025-06-07 13:06:23 +02:00
Psychi
da96945f39
fix regex match-group 2025-05-25 21:13:55 +02:00
Psychi
0bc9950b42
Fix Cp1252 encoding 2025-05-25 21:04:40 +02:00
Mathias Koehrer
c3660bee57 More compact view of folder list
With many folders, the line-height is too large.
It is reduced to make the view more compact.
Also, the top margin for the number of unread-folder will
be adapted.
2025-04-11 17:19:19 +02:00
the-djmaze
dea7f4d1d8 openssl_pkey_free is deprecated 2025-04-04 23:52:27 +02:00
6o6ep
00128e837f
incorrect using of the preposition 2025-04-02 10:22:23 +03:00
6o6ep
d9eaad24ad
incorrect using of the preposition 2025-04-02 10:21:05 +03:00
Roman Chutchev
9565559213
Update user.json 2025-03-11 23:10:42 +02:00
Roman Chutchev
1c9c9f0d6b
Merge branch 'the-djmaze:master' into fix-ru-lang 2025-03-11 22:37:35 +02:00
Maarten
d3b120bbe7
Merge pull request #1880 from tribut/i18n-de-fixes
Fix incorrect German translation
2025-03-10 16:45:38 +01:00
Maarten
a024d34f6d
Merge pull request #1891 from HeySora/master
Automatically add favicon host to CSP if present
2025-03-10 16:43:35 +01:00
the-djmaze
33fbaa9598 Bump version for #1918 2025-03-10 16:35:27 +01:00
Maarten
86da00eb50
Merge pull request #1918 from TomsProject/master
Changing Gravatar hashing algorithm to sha256
2025-03-10 16:34:17 +01:00
the-djmaze
45c8f71af0 Resolve #1883 2025-03-10 16:14:33 +01:00
the-djmaze
70aebb4981 Resolve #1930 2025-03-10 15:48:11 +01:00
Roman Chutchev
0233c83719
Update user.json 2025-03-08 20:51:14 +02:00
Leonard Jonathan Oh
e37546b795 Fix (docker): Ensure nginx does not listen on ipv6 in ipv4-only environments
The official nginx images do not listen on ipv6, very likely to avoid nginx crashing on startup in ipv4-only environments:

```
docker run --rm -it nginx:alpine cat /etc/nginx/conf.d/default.conf | grep -i listen
```

Hence, we simply need to detect whether ipv6 is disabled, and not listen on ipv6 if ipv6 is disabled in the kernel or in the container.
2025-02-26 06:25:09 +00:00
TomsProject
2741762199
Changing hashing algorithm to sha256 2025-02-22 18:32:21 +08:00
Simeon Goranov
ecbd13f099 Fix DateTimeHelper::ParseRFC2822DateString integer overflow on 32-bit PHP 2025-02-12 16:10:47 +02:00
HeySora
5bba3dac82
Automatically add favicon host to CSP if present 2025-01-14 13:27:26 +01:00
ZephOne
ed4b2dd4e1
Also map sSmtpUser to ldap login in plugin ldap-login-mapping 2025-01-09 16:30:26 +01:00
ZephOne
fad9d5e482
Explicit ldap-login-mapping plugin description 2025-01-09 16:28:12 +01:00
Felix Eckhofer
1660c62aa3
Fix incorrect German translation 2025-01-08 13:36:48 +01:00
Felix Eckhofer
dddf360db6
Add intermediate certificates for S/MIME signing 2025-01-07 21:32:26 +01:00
Baptiste Daroussin
7268dafffd fr: add an space before the colon
When composing a message in reply to an email, a colon is appended to
the message which is translated, in french colon should always be
prepended by an unbreakable space.

Add a space in the translation sentence to make sure the space appears
before the colon in the interface
2024-12-27 09:19:08 +01:00
the-djmaze
8bf4727555 Also detect Identity using from address #1865 2024-12-10 14:51:55 +01:00
Maarten
208b1fab62
Merge pull request #1857 from lenny87/cs-translation
Finished czech translation of Snappymail
2024-12-10 14:33:02 +01:00
Maarten
149cf5a120
Merge pull request #1861 from hungyao/patch-1
Update Html.js to remove x-html tags before rendering
2024-12-10 14:13:27 +01:00
Sergey Mosin
5973d71e2f Add an option to store(and delete) encrypted private key passphrase in the browser across multiple sessions 2024-12-09 11:18:41 -05:00
Sergey Mosin
486acde2e3 fix: properly convert minutes to milliseconds for debounce function Passphrases.handle 2024-12-08 19:52:49 -05:00
Andrew Tjia
e16d0b52ae
Update Html.js to remove x-html tags before rendering
Fix for https://github.com/the-djmaze/snappymail/issues/1860 to remove <x-html></x-html> tags that might be emitted by certain email clients.

Tested by modifying locally using JS debugger and Snappymail JS debug mode.
2024-12-07 09:37:33 +09:00
Sergey Mosin
c0348893af Delete OpenPGP keys on the server when they are deleted in the browser 2024-12-05 11:14:03 -05:00
Jakub Lansky
0ffd97a183
Fixed typo in Spam folder name 2024-12-04 17:14:00 +01:00
Jakub Lansky
2143051eaf
Finished czech translation of Snappymail 2024-12-04 14:42:06 +01:00
Maarten
7eaa065d76
Merge pull request #1843 from edlerd/add-spf-dmarc
Add dmarc and spf indicators in mail view
2024-11-26 03:01:32 +01:00
the-djmaze
7a465606bc Resolve #1849 2024-11-26 02:47:17 +01:00
the-djmaze
cd3398db20 Resolve #1850 2024-11-26 01:47:34 +01:00
David Edler
18da535643
Show icon if no information is avilable on dkip, dmark or spf
Signed-off-by: David Edler <david.edler@canonical.com>
2024-11-18 17:20:57 +01:00
Maarten
d301b93b0d
Merge pull request #1845 from SergeyMosin/compact-composer-plugin-v1.0.6
Update squire to v2.3.2 and fix willPaste listener/sanitizer
2024-11-18 16:01:52 +01:00
the-djmaze
66553a36da Improvements for #1846 2024-11-18 16:00:03 +01:00
Sergey Mosin
95af4f60ea update squire to v2.3.2 and fix willPaste listener/sanitizer 2024-11-16 18:16:32 -05:00
David Edler
3ee978484a
Add dmarc and spf indicators in mail view #1820
Signed-off-by: David Edler <david.edler@canonical.com>
2024-11-12 18:03:38 +01:00
the-djmaze
8b5547104d Only allow Proxy for logged in users 2024-11-12 00:35:51 +01:00
the-djmaze
0d5d403fb8 Resolve #1825 2024-11-12 00:00:11 +01:00
the-djmaze
91ff43e6ab Resolve #1828 2024-11-11 23:57:41 +01:00
the-djmaze
bc6a2b67c0 Cleanup KnockoutJS a bit 2024-10-30 01:08:18 +01:00
the-djmaze
4cd5774257 ko.utils.domNodeDisposal.addDisposeCallback to ko.addDisposeCallback 2024-10-29 22:19:26 +01:00
the-djmaze
c744b83480 Put knockout bind context Proxy outside bindingFunction 2024-10-29 17:16:22 +01:00
the-djmaze
3d55bdaf5a Added test contacts CardDAV settings 2024-10-29 13:17:30 +01:00
the-djmaze
2809e75307 Preload identicon on delayed avatar lookup 2024-10-29 11:33:46 +01:00
the-djmaze
a83c3aacf8 Set identicon in cache when loading remote avatar fails 2024-10-29 01:29:52 +01:00
the-djmaze
8645b0ab1e Speedup identicons by caching them 2024-10-29 01:20:34 +01:00
the-djmaze
490d808cdb Speedup handling of identical avatars 2024-10-29 00:59:28 +01:00
the-djmaze
f12de9a3fd Improve contacts layout 2024-10-28 12:02:50 +01:00
the-djmaze
1b4bf3b8c3 Rename Identity.formattedName to Identity.toString 2024-10-28 12:01:59 +01:00
the-djmaze
64e780e500 Resolve #1813 2024-10-27 13:26:05 +01:00
the-djmaze
709d67226c Resolve #1815 2024-10-27 13:08:52 +01:00
Maarten
cab3b2b78a
Merge pull request #1822 from ner00/master
Update Portuguese
2024-10-27 12:39:19 +01:00
ner0
c50edb826b Update Portuguese 2024-10-23 12:09:55 +01:00
Maarten
332f9a79e3
Merge pull request #1816 from romawkafor/master
Please update user.json in Ukraine language.
2024-10-23 01:28:27 +02:00
the-djmaze
534b71e5a4 On reply, set from field to the received email to field #1805 2024-10-23 01:27:29 +02:00
the-djmaze
f0b3d798bc IdentityModel extends EmailModel 2024-10-23 00:56:42 +02:00
the-djmaze
b01072e2e3 Improved some templates 2024-10-23 00:52:36 +02:00
the-djmaze
bce9758338 Fix Knockout binding non-Observable notice 2024-10-23 00:52:01 +02:00
the-djmaze
3a589e240c Proper fix for default sortMode 2024-10-23 00:39:01 +02:00
the-djmaze
6bdd509b48 Cleanup knockout a bit 2024-10-23 00:37:48 +02:00
the-djmaze
4f6783a991 Bugfix: sortMode undefined at login 2024-10-23 00:28:13 +02:00
the-djmaze
012c1102f9 Merge branch 'master' of github.com:the-djmaze/snappymail 2024-10-15 22:00:53 +02:00
the-djmaze
78547e8c68 Resolve #1806 2024-10-15 22:00:33 +02:00
Roman Pereviznyk
fdc1a1c924
Update user.json
Minnor changes
2024-10-15 16:55:09 +02:00
Maarten
f877b849eb
Merge pull request #1810 from romawkafor/master
Corrections and addition of Ukrainian language localization files
2024-10-15 16:05:09 +02:00
Roman Pereviznyk
a1f890e3b4
Update user.json
I am adding some changes
2024-10-15 00:23:40 +03:00
Roman Pereviznyk
7aa87da449
Create admin.json
I am adding the possibility to use the Ukrainian language for the admin panel
2024-10-14 19:03:36 +03:00
Roman Pereviznyk
64f74d0511
Update zh.ini
Removed the typo in lines 15 and 16 (the \n symbol at the end of the tape)
2024-10-14 17:53:38 +03:00
Roman Pereviznyk
5d679b5be5
Update it.ini
Removed the typo in lines 15 and 16 (the \n symbol at the end of the tape)
2024-10-14 17:52:27 +03:00
Roman Pereviznyk
6bc658b5e0
Update hu.ini
Removed the typo in lines 15 and 16 (the \n symbol at the end of the tape)
2024-10-14 17:51:57 +03:00
Roman Pereviznyk
bc6de6c336
Update en.ini
Removed the typo in lines 15 and 16 (the \n symbol at the end of the tape)
2024-10-14 17:51:08 +03:00
Roman Pereviznyk
583528c448
Update cs.ini
Removed the typo in lines 15 and 16 (the \n symbol at the end of the tape)
2024-10-14 17:50:38 +03:00
Roman Pereviznyk
6307470ac0
Create uk.ini
Created a localization file for the Ukrainian language for the two-factor authentication application
2024-10-14 17:48:18 +03:00
Roman Pereviznyk
35914c0406
Create uk.ini
Created a localization file for the Ukrainian language for the password change application.
2024-10-14 17:32:59 +03:00
Roman Pereviznyk
5861124c2a
Update user.json
I propose changes for the Ukrainian language, taking into account syntax and untranslated phrases.

In a short time, I will try to create and translate an admin page (admin.json) and 2-Step Verification (TOTP), which I will also offer
2024-10-14 13:50:31 +03:00
Maarten
0ef2987911
Merge pull request #1799 from hguilbert/patch-31
Update user.json
2024-10-13 18:19:44 +02:00
Maarten
3d970e4706
Merge pull request #1800 from tinola/master
Update user.json
2024-10-13 18:19:16 +02:00
tinola
838e73fa94
Update user.json
Update Polish translation
2024-10-11 07:43:08 +02:00
hguilbert
42c98d42e0
Update user.json 2024-10-11 05:54:05 +02:00
the-djmaze
055c0d3bf4 Added setting for default messages sort mode 2024-10-11 00:28:05 +02:00
the-djmaze
16106a28d5 Resolve #1798 2024-10-10 23:45:28 +02:00
the-djmaze
9eb7f38485 KnockoutJS secure bindings using Proxy 2024-10-10 00:54:30 +02:00
the-djmaze
82ab4c4c6d Use CSS instead of $index #1797 2024-10-10 00:54:19 +02:00
241 changed files with 8634 additions and 6045 deletions

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM alpine:3.18.5 AS builder FROM alpine:3.24.3 AS builder
RUN apk add --no-cache php82 php82-json php-phar php-zip RUN apk add --no-cache php85 php85-json php-phar php-zip
RUN apk add --no-cache npm RUN apk add --no-cache npm
RUN npm install -g gulp yarn RUN npm install -g gulp yarn
WORKDIR /source WORKDIR /source
@ -23,7 +23,7 @@ RUN set -eux; \
rm -v /snappymail/README.md /snappymail/_include.php rm -v /snappymail/README.md /snappymail/_include.php
# Inspired by the original Rainloop dockerfile from youtous on GitLab # Inspired by the original Rainloop dockerfile from youtous on GitLab
FROM php:8.2-fpm-alpine AS final FROM php:8.5-fpm-alpine AS final
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine" LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine"
@ -99,9 +99,6 @@ RUN set -eux; \
# mysql # mysql
RUN docker-php-ext-install pdo_mysql RUN docker-php-ext-install pdo_mysql
# opcache
RUN docker-php-ext-install opcache
# postgres # postgres
RUN set -eux; \ RUN set -eux; \
apk add --no-cache postgresql-libs; \ apk add --no-cache postgresql-libs; \
@ -122,6 +119,13 @@ RUN set -eux; \
apk del .build-dependencies; \ apk del .build-dependencies; \
apk del .deps apk del .deps
# sqlite
RUN set -eux; \
apk add --no-cache sqlite-libs; \
apk add --no-cache --virtual .deps sqlite-dev; \
docker-php-ext-install pdo_sqlite; \
apk del .deps
# tidy # tidy
RUN set -eux; \ RUN set -eux; \
apk add --no-cache tidyhtml; \ apk add --no-cache tidyhtml; \

View file

@ -9,6 +9,12 @@ UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
MEMORY_LIMIT=${MEMORY_LIMIT:-128M} MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
SECURE_COOKIES=${SECURE_COOKIES:-true} SECURE_COOKIES=${SECURE_COOKIES:-true}
# Disable listening on ipv6 for ipv4-only environments, or nginx will fail to start
if ! ip a | grep 'inet6 ' > /dev/null; then
echo "[INFO] ipv6 is disabled. Configuring nginx to not listen on ipv6"
sed -i '/listen \[::\]:/d' /etc/nginx/nginx.conf
fi
# Set attachment size limit # Set attachment size limit
sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf
sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf

View file

@ -36,5 +36,5 @@ YMXQVq4SJt7UpWABAORudfJxsBqCRKtPlZMgGTJLjcOkyFJ9C2Fx7DeN0J4I
## Publishing and Credits ## Publishing and Credits
I will analyze and fix the reported issue as fast as possible. I will analyze and fix the reported issue as fast as possible.
Together with the reporter I plan the disclosure of the found and fixed vulnerability. Together with the reporter, I plan the disclosure of the found and fixed vulnerability.
Credits to the reporter are granted and can be included in all public communication if desired. Credits to the reporter are granted and can be included in all public communication if desired.

View file

@ -9,7 +9,7 @@ if (isset($options['plugins'])) {
require(ROOT_DIR . '/build/plugins.php'); require(ROOT_DIR . '/build/plugins.php');
} }
$gulp = trim(`which gulp`); $gulp = trim(shell_exec('which gulp'));
if (!$gulp) { if (!$gulp) {
exit('gulp not installed, run as root: npm install --global gulp-cli'); exit('gulp not installed, run as root: npm install --global gulp-cli');
} }
@ -34,7 +34,7 @@ $options['aur'] = isset($options['aur']);
// Docker build // Docker build
$options['docker'] = isset($options['docker']); $options['docker'] = isset($options['docker']);
if ($options['docker'] && !($docker = trim(`which docker`))) { if ($options['docker'] && !($docker = trim(shell_exec('which docker')))) {
exit('docker not found'); exit('docker not found');
} }
if ($options['docker'] && $options['aur']) { if ($options['docker'] && $options['aur']) {
@ -60,7 +60,7 @@ if (!isset($options['skip-gulp'])) {
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static'; $cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
if ($gzip = trim(`which gzip`)) { if ($gzip = trim(shell_exec('which gzip'))) {
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n"; echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
passthru("{$gzip} -k --best {$cmddir}/js/*.js"); passthru("{$gzip} -k --best {$cmddir}/js/*.js");
passthru("{$gzip} -k --best {$cmddir}/js/min/*.js"); passthru("{$gzip} -k --best {$cmddir}/js/min/*.js");
@ -70,7 +70,7 @@ if (!isset($options['skip-gulp'])) {
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz'); unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz');
} }
if ($brotli = trim(`which brotli`)) { if ($brotli = trim(shell_exec('which brotli'))) {
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n"; echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
passthru("{$brotli} -k --best {$cmddir}/js/*.js"); passthru("{$brotli} -k --best {$cmddir}/js/*.js");
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js"); passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
@ -188,7 +188,7 @@ file_put_contents("{$destPath}core.json", '{
// Arch User Repository // Arch User Repository
if ($options['aur']) { if ($options['aur']) {
// extension_loaded('blake2') // extension_loaded('blake2')
if (!function_exists('b2sum') && $b2sum = trim(`which b2sum`)) { if (!function_exists('b2sum') && $b2sum = trim(shell_exec('which b2sum'))) {
function b2sum($file) { function b2sum($file) {
$file = escapeshellarg($file); $file = escapeshellarg($file);
exec("b2sum --binary {$file} 2>&1", $output, $exitcode); exec("b2sum --binary {$file} 2>&1", $output, $exitcode);

View file

@ -1,6 +1,7 @@
import 'External/User/ko'; import 'External/User/ko';
import { SMAudio } from 'Common/Audio'; import { SMAudio } from 'Common/Audio';
import { arrayLength } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser'; import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
import { import {
@ -240,7 +241,15 @@ export class AppUser extends AbstractApp {
showMessageComposer(params = []) showMessageComposer(params = [])
{ {
showScreenPopup(ComposePopupView, params); let msg = params[1];
if (1 == arrayLength(msg)) {
msg = msg[0];
}
if (msg) {
msg.decrypt().then((/*success*/)=>showScreenPopup(ComposePopupView, params));
} else {
showScreenPopup(ComposePopupView, params);
}
} }
} }
@ -251,7 +260,8 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
view => resolve({ view => resolve({
password:view.passphrase(), password:view.passphrase(),
username:/*ask & 2 ? */view.username(), username:/*ask & 2 ? */view.username(),
remember:/*ask & 4 ? */view.remember() remember:/*ask & 4 ? */view.remember(),
rememberPermanent: view.rememberPermanent(),
}), }),
() => resolve(null), () => resolve(null),
true, true,

View file

@ -232,6 +232,7 @@ export const
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'), detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()), bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
minFontSize = parseInt(SettingsUserStore.minFontSize()),
result = { result = {
hasExternals: false, hasExternals: false,
@ -257,6 +258,15 @@ export const
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px' rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
}, },
allowedAttributes = [ allowedAttributes = [
// SnappyMail
// 'data-x-href-broken',
// 'data-x-href-tracking',
'data-x-src',
// 'data-x-src-hidden',
// 'data-x-src-tracking',
// 'data-x-src-broken',
// 'data-x-style-url',
// 'data-x-style-broken-urls',
// defaults // defaults
'name', 'name',
'dir', 'lang', 'style', 'title', 'dir', 'lang', 'style', 'title',
@ -299,6 +309,8 @@ export const
// Not supported by <template> element // Not supported by <template> element
// .replace(/<!doctype[^>]*>/gi, '') // .replace(/<!doctype[^>]*>/gi, '')
// .replace(/<\?xml[^>]*\?>/gi, '') // .replace(/<\?xml[^>]*\?>/gi, '')
// https://github.com/the-djmaze/snappymail/issues/1860
.replace(/<(\/?)x-html(\s[^>]*)?>/gi, '')
.replace(/<(\/?)head(\s[^>]*)?>/gi, '') .replace(/<(\/?)head(\s[^>]*)?>/gi, '')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>') .replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
// .replace(/<\/?(html|head)[^>]*>/gi, '') // .replace(/<\/?(html|head)[^>]*>/gi, '')
@ -493,12 +505,12 @@ export const
let attachment; let attachment;
if (value.startsWith('cid:')) if (value.startsWith('cid:'))
{ {
setAttribute('data-x-src', value);
value = value.slice(4); value = value.slice(4);
setAttribute('data-x-src-cid', value);
attachment = findAttachmentByCid(value); attachment = findAttachmentByCid(value);
if (attachment?.download) { if (attachment?.download) {
oElement.src = attachment.linkPreview(); oElement.src = attachment.linkPreview();
oElement.title += ' ('+attachment.fileName+')'; // oElement.title || (oElement.title = '+attachment.fileName);
attachment.isInline(true); attachment.isInline(true);
attachment.isLinked(true); attachment.isLinked(true);
} }
@ -614,12 +626,13 @@ export const
if (urls_broken.length) { if (urls_broken.length) {
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken)); setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
} }
/*
// https://github.com/the-djmaze/snappymail/issues/1082 // https://github.com/the-djmaze/snappymail/issues/1082
if (11 > pInt(oStyle.fontSize)) { if (minFontSize > pInt(oStyle.fontSize)) {
oStyle.removeProperty('font-size'); oStyle.fontSize = minFontSize + "px";
// oStyle.removeProperty('font-size');
} }
*/
// Removes background and color // Removes background and color
// Many e-mails incorrectly only define one, not both // Many e-mails incorrectly only define one, not both
// And in dark theme mode this kills the readability // And in dark theme mode this kills the readability

View file

@ -97,7 +97,7 @@ Object.assign(ko.bindingHandlers, {
fValue.subscribe(fUpdateEditorValue); fValue.subscribe(fUpdateEditorValue);
// ko.utils.domNodeDisposal.addDisposeCallback(element, () => { // ko.addDisposeCallback(element, () => {
// }); // });
} }
} }

7
dev/External/ko.js vendored
View file

@ -15,6 +15,7 @@ export const
* based solely on the values of other observables in the application * based solely on the values of other observables in the application
*/ */
koComputable = fn => ko.computed(fn, {'pure':true}), koComputable = fn => ko.computed(fn, {'pure':true}),
// koObservable = value => ko.observable(value),
addObservablesTo = (target, observables) => addObservablesTo = (target, observables) =>
forEachObjectEntry(observables, (key, value) => forEachObjectEntry(observables, (key, value) =>
@ -30,7 +31,7 @@ export const
onEvent = (element, event, fn) => { onEvent = (element, event, fn) => {
element.addEventListener(event, fn); element.addEventListener(event, fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn)); ko.addDisposeCallback(element, () => element.removeEventListener(event, fn));
}, },
onKey = (key, element, fValueAccessor, fAllBindings, model) => { onKey = (key, element, fValueAccessor, fAllBindings, model) => {
@ -175,6 +176,6 @@ ko.extenders.falseTimeout = (target, option) => {
// functions // functions
ko.observable.fn.askDeleteHelper = function() { ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] }); return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
}; };

View file

@ -73,4 +73,9 @@ export class EmailModel extends AbstractModel {
} }
return result || name; return result || name;
} }
get domain() {
return this.email.replace(/^.+@([^@]+)$/, '$1');
// return this.email.slice(this.email.lastIndexOf('@'))
}
} }

View file

@ -1,19 +1,16 @@
import { AbstractModel } from 'Knoin/AbstractModel'; //import { AbstractModel } from 'Knoin/AbstractModel';
import { EmailModel } from 'Model/Email';
import { addObservablesTo, addComputablesTo } from 'External/ko'; import { addObservablesTo, addComputablesTo } from 'External/ko';
export class IdentityModel extends AbstractModel { export class IdentityModel extends EmailModel /*AbstractModel*/ {
/**
* @param {string} id
* @param {string} email
*/
constructor() { constructor() {
super(); super();
addObservablesTo(this, { addObservablesTo(this, {
id: '', id: '',
label: '', label: '',
email: '', // email: '',
name: '', // name: '',
replyTo: '', replyTo: '',
bcc: '', bcc: '',
@ -27,6 +24,7 @@ export class IdentityModel extends AbstractModel {
smimeKey: '', smimeKey: '',
smimeCertificate: '', smimeCertificate: '',
smimeCertificateChain: '',
askDelete: false, askDelete: false,
@ -36,16 +34,18 @@ export class IdentityModel extends AbstractModel {
addComputablesTo(this, { addComputablesTo(this, {
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'), smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()), smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate()) smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate()),
smimeCertificateChainValid: () => !this.smimeCertificateChain()
|| /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificateChain())
}); });
} }
/** /**
* @returns {string} * @returns {string}
*/ */
formattedName() { toString() {
const name = this.name(), const name = this.name,
email = this.email(), email = this.email,
label = this.label(); label = this.label();
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : ''); return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
} }

View file

@ -24,6 +24,10 @@ import Remote from 'Remote/User/Fetch';
import { MimeToMessage } from 'Mime/Utils'; import { MimeToMessage } from 'Mime/Utils';
import { PgpUserStore } from 'Stores/User/Pgp';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const const
PreviewHTML = `<html> PreviewHTML = `<html>
<head> <head>
@ -598,4 +602,140 @@ export class MessageModel extends AbstractModel {
return result.html || plainToHtml(this.plain()); return result.html || plainToHtml(this.plain());
} }
async decrypt() {
const msg = this;
if (msg.pgpEncrypted() && !msg.pgpDecrypted()) {
return await msg.pgpDecrypt().then(()=>msg.pgpDecrypted());
}
if (msg.smimeEncrypted() && !msg.smimeDecrypted()) {
return await msg.smimeDecrypt().then(()=>msg.smimeDecrypted());
}
return true;
}
/*
async decrypted() {
return msg.pgpDecrypted() || msg.smimeDecrypted());
}
*/
async pgpDecrypt() {
const oMessage = this,
data = oMessage.pgpEncrypted();
delete data.error;
await PgpUserStore.decrypt(oMessage).then(result => {
if (!result) {
// TODO: translate
throw Error('Decryption failed, canceled or not possible');
}
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
})
.catch(e => {
data.error = e.message;
})
.finally(() => {
oMessage.pgpEncrypted(data);
});
}
pgpVerify(/*self, event*/) {
const oMessage = this;
PgpUserStore.verify(oMessage).then(result => {
if (result) {
oMessage.pgpSigned(result);
} else {
alert('Verification failed or no valid public key found');
}
/*
if (result?.success) {
i18n('CRYPTO/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
});
message.getText()
} else {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
: '';
i18n('CRYPTO/ERROR', {
TYPE: 'OpenPGP',
ERROR: 'message'
}) + (additional ? ' (' + additional + ')' : '');
}
*/
});
}
async smimeDecrypt() {
const message = this;
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
identity = IdentityUserStore.find(item => addresses.includes(item.email)),
data = message.smimeEncrypted(); // { partId: "1" }
if (data && identity) {
delete data.error;
let pass, params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
// params.bodyPart = params.bodyPart?.raw;
params.certificate = identity.smimeCertificate();
params.privateKey = identity.smimeKey();
if (identity.smimeKeyEncrypted()) {
pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
'CRYPTO/DECRYPT'
);
if (!pass) {
return;
}
params.passphrase = pass?.password;
}
await Remote.post('SMimeDecryptMessage', null, params).then(response => {
if (response?.Result?.data) {
message.smimeDecrypted(true);
MimeToMessage(response.Result.data, message);
message.html() ? message.viewHtml() : message.viewPlain();
pass && pass.remember && Passphrases.handle(identity, pass.password);
if ('signed' in response.Result) {
message.smimeSigned(response.Result.signed);
}
}
}).catch(e => {
data.error = e.message
})
.finally(() => {
message.smimeEncrypted(data);
});
}
}
smimeVerify(/*self, event*/) {
const message = this,
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
if (data) {
const params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
params.bodyPart = data.bodyPart?.raw;
params.sigPart = data.sigPart?.bodyRaw;
Remote.post('SMimeVerifyMessage', null, params).then(response => {
if (response?.Result) {
if (response.Result.body) {
MimeToMessage(response.Result.body, message);
message.html() ? message.viewHtml() : message.viewPlain();
}
data.success = response.Result.success;
message.smimeSigned(data);
}
});
}
}
} }

View file

@ -18,8 +18,8 @@ export class AdminSettingsDomains /*extends AbstractViewSettings*/ {
testUsername() { testUsername() {
Remote.request('AdminDomainMatch', Remote.request('AdminDomainMatch',
(iError, oData) => { (iError, oData) => {
if (oData?.Result?.domain) { if (oData?.Result?.name) {
alert(`${oData.Result.email} matched domain: ${oData.Result.domain.name}`); alert(`${oData.Result.email} matched domain: ${oData.Result.name}`);
} else { } else {
alert('No domain match'); alert('No domain match');
} }

View file

@ -2,7 +2,7 @@ import ko from 'ko';
import { koComputable } from 'External/ko'; import { koComputable } from 'External/ko';
import { SettingsGet } from 'Common/Globals'; import { SettingsGet } from 'Common/Globals';
import { i18n, translateTrigger } from 'Common/Translator'; import { i18n, translateTrigger, getErrorMessage } from 'Common/Translator';
import { ContactUserStore } from 'Stores/User/Contact'; import { ContactUserStore } from 'Stores/User/Contact';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
@ -15,6 +15,7 @@ export class UserSettingsContacts /*extends AbstractViewSettings*/ {
this.syncUrl = ContactUserStore.syncUrl; this.syncUrl = ContactUserStore.syncUrl;
this.syncUser = ContactUserStore.syncUser; this.syncUser = ContactUserStore.syncUser;
this.syncPass = ContactUserStore.syncPass; this.syncPass = ContactUserStore.syncPass;
this.syncError = ko.observable('');
this.syncModeOptions = koComputable(() => { this.syncModeOptions = koComputable(() => {
translateTrigger(); translateTrigger();
@ -48,4 +49,15 @@ export class UserSettingsContacts /*extends AbstractViewSettings*/ {
}) })
); );
} }
test() {
this.syncError('');
Remote.request('TestContactsSyncData', (iError, data) => {
iError && this.syncError(data.messageAdditional || data.message || getErrorMessage(iError, data));
}, {
Url: ContactUserStore.syncUrl(),
User: ContactUserStore.syncUser(),
Password: ContactUserStore.syncPass()
})
}
} }

View file

@ -44,6 +44,51 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.desktopNotifications = NotificationUserStore.enabled; this.desktopNotifications = NotificationUserStore.enabled;
this.isDesktopNotificationAllowed = NotificationUserStore.allowed; this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
const i18nSort = s => i18n('MESSAGE_LIST/SORT_'+s);
this.sortSupported = FolderUserStore.hasCapability('SORT');
this.sortOptions = [
{
value: '',
text: i18nSort('DATE_DESC')
},
{
value: 'DATE',
text: i18nSort('DATE_ASC')
},
{
value: 'FROM',
text: i18nSort('FROM_ASC')
},
{
value: 'REVERSE FROM',
text: i18nSort('FROM_DESC')
},
{
value: 'REVERSE SIZE',
text: i18nSort('SIZE_DESC')
},
{
value: 'SIZE',
text: i18nSort('SIZE_ASC')
},
{
value: 'SUBJECT',
text: i18nSort('SUBJECT_ASC')
},
{
value: 'REVERSE SUBJECT',
text: i18nSort('SUBJECT_DESC')
},
{
value: 'REVERSE ARRIVAL',
text: i18nSort('ARRIVAL_DESC')
},
{
value: 'ARRIVAL',
text: i18nSort('ARRIVAL_ASC')
}
];
this.threadsAllowed = AppUserStore.threadsAllowed; this.threadsAllowed = AppUserStore.threadsAllowed;
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT' // 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
this.threadAlgorithms = ko.observableArray(); this.threadAlgorithms = ko.observableArray();
@ -51,12 +96,11 @@ export class UserSettingsGeneral extends AbstractViewSettings {
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7)) capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
); );
this.threadAlgorithms.sort((a, b) => a.length - b.length); this.threadAlgorithms.sort((a, b) => a.length - b.length);
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
['useThreads', 'threadAlgorithm', ['defaultSort', 'useThreads', 'threadAlgorithm',
// These use addSetting() // These use addSetting()
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval', 'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel', 'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel', 'minFontSize',
// These are in addSettings() // These are in addSettings()
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt', 'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave', 'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
@ -78,7 +122,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
identityMainDesc: () => { identityMainDesc: () => {
const identity = IdentityUserStore.main(); const identity = IdentityUserStore.main();
return identity ? identity.formattedName() : '---'; return identity ? identity.toString() : '---';
}, },
editorDefaultTypes: () => { editorDefaultTypes: () => {
@ -117,6 +161,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.addSetting('CheckMailInterval'); this.addSetting('CheckMailInterval');
this.addSetting('Layout'); this.addSetting('Layout');
this.addSetting('MaxBlockquotesLevel'); this.addSetting('MaxBlockquotesLevel');
this.addSetting('minFontSize');
this.addSetting('defaultSort');
this.addSettings([ this.addSettings([
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt', 'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',

View file

@ -1,21 +1,155 @@
import { AskPopupView } from 'View/Popup/Ask'; import { AskPopupView } from 'View/Popup/Ask';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import { SettingsGet } from '../Common/Globals';
import { isArray } from '../Common/Utils';
export const Passphrases = new WeakMap(); export const Passphrases = new WeakMap();
Passphrases.ask = async (key, sAskDesc, btnText) => Passphrases.ask = async (key, sAskDesc, btnText) => {
Passphrases.has(key) if (Passphrases.has(key)) {
? {password:Passphrases.handle(key)/*, remember:false*/} return { password: Passphrases.handle(key)/*, remember:false*/ };
: await AskPopupView.password(sAskDesc, btnText, 5); } else if (Passphrases.hasInLocalStorage(key)) {
return { password: await getFromLocalStorage(key) };
} else {
const pass = await AskPopupView.password(sAskDesc, btnText,
window.crypto.subtle && canUseLocalStorage(key) ? 0b1101 : 0b0101);
pass.rememberPermanent && await saveToLocalStorage(key, pass.password);
return pass;
}
};
Passphrases._deleteFromSession = Passphrases.delete;
const timeouts = {}; const timeouts = {};
// get/set accessor to control deletion after N minutes of inactivity // get/set accessor to control deletion after N minutes of inactivity
Passphrases.handle = (key, pass) => { Passphrases.handle = (key, pass) => {
const timeout = SettingsUserStore.keyPassForget(); const timeout = SettingsUserStore.keyPassForget();
if (timeout && !timeouts[key]) { if (timeout && !timeouts[key]) {
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 1000); timeouts[key] = (() => Passphrases._deleteFromSession(key)).debounce(timeout * 60 * 1000);
} }
pass && Passphrases.set(key, pass); pass && Passphrases.set(key, pass);
timeout && timeouts[key](); timeout && timeouts[key]();
return Passphrases.get(key); return Passphrases.get(key);
}; };
const deleteFromLocalStorage = (key) => {
const keyId = getKeyId(key);
if (keyId) {
localStorage.removeItem(keyId);
}
};
Passphrases.delete = (key) => {
deleteFromLocalStorage(key);
return Passphrases._deleteFromSession(key);
};
Passphrases.hasInLocalStorage = (key) => {
const keyId = getKeyId(key);
return keyId && localStorage.getItem(keyId) !== null;
};
const saveToLocalStorage = async (key, pass) => {
const keyId = getKeyId(key);
if (!keyId) {
return;
}
if (!pass) {
localStorage.removeItem(keyId);
return;
}
try {
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), salt);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
derivedKey,
new TextEncoder().encode(pass)
);
localStorage.setItem(keyId, JSON.stringify([
btoa(String.fromCharCode.apply(null, salt)),
btoa(String.fromCharCode.apply(null, iv)),
btoa(String.fromCharCode.apply(null, new Uint8Array(encrypted)))
]));
} catch (e) {
console.error('Passphrases.saveToLocalStorage failed', e);
}
};
const getFromLocalStorage = async (key) => {
const keyId = getKeyId(key);
if (!keyId) {
return undefined;
}
const jsonData = localStorage.getItem(keyId);
if (!jsonData) {
console.error('Passphrases.getFromLocalStorage failed: no data found');
return undefined;
}
try {
const saltIvData = JSON.parse(jsonData);
if (!saltIvData || !isArray(saltIvData) || saltIvData.length !== 3) {
// noinspection ExceptionCaughtLocallyJS
throw new Error('invalid passphrase data');
}
const toUint8 = (str) => new Uint8Array(atob(str).split('').map(c => c.charCodeAt(0)));
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), toUint8(saltIvData[0]));
const decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: toUint8(saltIvData[1])
},
derivedKey,
toUint8(saltIvData[2])
);
return String.fromCharCode.apply(null, new Uint8Array(decrypted));
} catch (e) {
localStorage.removeItem(keyId);
console.error('Passphrases.getFromLocalStorage failed', e);
return undefined;
}
};
const canUseLocalStorage = (key) => getKeyId(key) !== undefined;
const getKeyId = (key) => {
if (key && typeof key.id === 'string' && key.id.length > 4 && typeof key.forgetPass === 'function') {
// only deal with keys that we can forget (OpenPGB, GnuPG)
return key.id + '_local_key';
} else {
console.info('Passphrases.getKeyId: unsupported key type');
return undefined;
}
};
const deriveKeyFromHash = async (hash, salt) => {
if (!hash) {
throw new Error('empty accountHash');
}
return window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(hash),
{ 'name': 'PBKDF2' },
false,
['deriveKey']
).then(keyMaterial => {
return window.crypto.subtle.deriveKey(
{
'name': 'PBKDF2',
'salt': salt,
'iterations': 512,
'hash': 'SHA-256'
},
keyMaterial,
{ 'name': 'AES-GCM', 'length': 256 },
false,
['encrypt', 'decrypt']
);
});
};

View file

@ -51,6 +51,13 @@ export const GnuPGUserStore = new class {
key.for = email => aEmails.includes(IDN.toASCII(email)); key.for = email => aEmails.includes(IDN.toASCII(email));
key.askDelete = ko.observable(false); key.askDelete = ko.observable(false);
key.openForDeletion = ko.observable(null).askDeleteHelper(); key.openForDeletion = ko.observable(null).askDeleteHelper();
key.askForgetPass = ko.observable(false);
key.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
key.forgetPass = () => {
Passphrases.delete(key);
key.hasStoredPass(false);
};
key.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(key));
key.remove = () => { key.remove = () => {
if (key.askDelete()) { if (key.askDelete()) {
Remote.request('GnupgDeleteKey', Remote.request('GnupgDeleteKey',
@ -69,6 +76,7 @@ export const GnuPGUserStore = new class {
isPrivate: isPrivate isPrivate: isPrivate
} }
); );
isPrivate && key.forgetPass();
} }
}; };
if (isPrivate) { if (isPrivate) {

View file

@ -13,7 +13,7 @@ import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
import { Passphrases } from 'Storage/Passphrases'; import { Passphrases } from 'Storage/Passphrases';
import { baseCollator } from 'Common/Translator'; import { baseCollator, getNotification } from 'Common/Translator';
const const
loaded = () => !!window.openpgp, loaded = () => !!window.openpgp,
@ -58,10 +58,9 @@ const
*/ */
publicKeysItem = 'openpgp-public-keys', publicKeysItem = 'openpgp-public-keys',
privateKeysItem = 'openpgp-private-keys', privateKeysItem = 'openpgp-private-keys',
storage = window.localStorage,
loadOpenPgpKeys = async itemname => { loadOpenPgpKeys = async itemname => {
let keys = [], key, let keys = [], key,
armoredKeys = JSON.parse(storage.getItem(itemname)), armoredKeys = JSON.parse(localStorage.getItem(itemname)),
i = arrayLength(armoredKeys); i = arrayLength(armoredKeys);
while (i--) try { while (i--) try {
key = await openpgp.readKey({armoredKey:armoredKeys[i]}); key = await openpgp.readKey({armoredKey:armoredKeys[i]});
@ -74,9 +73,9 @@ const
storeOpenPgpKeys = (keys, section) => { storeOpenPgpKeys = (keys, section) => {
let armoredKeys = keys.map(item => item.armor); let armoredKeys = keys.map(item => item.armor);
if (armoredKeys.length) { if (armoredKeys.length) {
storage.setItem(section, JSON.stringify(armoredKeys)); localStorage.setItem(section, JSON.stringify(armoredKeys));
} else { } else {
storage.removeItem(section); localStorage.removeItem(section);
} }
}; };
@ -91,6 +90,9 @@ class OpenPgpKeyModel {
this.armor = armor; this.armor = armor;
this.askDelete = ko.observable(false); this.askDelete = ko.observable(false);
this.openForDeletion = ko.observable(null).askDeleteHelper(); this.openForDeletion = ko.observable(null).askDeleteHelper();
this.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(this));
this.askForgetPass = ko.observable(false);
this.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
// key.getUserIDs() // key.getUserIDs()
// key.getPrimaryUser() // key.getPrimaryUser()
} }
@ -116,12 +118,29 @@ class OpenPgpKeyModel {
if (this.key.isPrivate()) { if (this.key.isPrivate()) {
OpenPGPUserStore.privateKeys.remove(this); OpenPGPUserStore.privateKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem); storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
this.forgetPass()
} else { } else {
OpenPGPUserStore.publicKeys.remove(this); OpenPGPUserStore.publicKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem); storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem);
} }
Remote.request('DeletePGPKey',
(iError, oData) => {
if (oData) {
if (iError || oData.Result === false) {
alert(oData.message || getNotification(iError));
}
}
}, {
key: this.armor
}
);
} }
} }
forgetPass() {
Passphrases.delete(this);
this.hasStoredPass(false);
}
/* /*
toJSON() { toJSON() {
return this.armor; return this.armor;

View file

@ -5,6 +5,7 @@ import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
import { pInt } from 'Common/Utils'; import { pInt } from 'Common/Utils';
import { $htmlCL, SettingsGet, fireEvent } from 'Common/Globals'; import { $htmlCL, SettingsGet, fireEvent } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme'; import { ThemeStore } from 'Stores/Theme';
import { FolderUserStore } from 'Stores/User/Folder';
export const SettingsUserStore = new class { export const SettingsUserStore = new class {
constructor() { constructor() {
@ -22,6 +23,7 @@ export const SettingsUserStore = new class {
allowStyles: 0, allowStyles: 0,
collapseBlockquotes: 1, collapseBlockquotes: 1,
maxBlockquotesLevel: 0, maxBlockquotesLevel: 0,
minFontSize: 0,
listInlineAttachments: 0, listInlineAttachments: 0,
simpleAttachmentsList: 0, simpleAttachmentsList: 0,
useCheckboxesInList: 1, useCheckboxesInList: 1,
@ -29,6 +31,7 @@ export const SettingsUserStore = new class {
showNextMessage: 0, showNextMessage: 0,
allowDraftAutosave: 1, allowDraftAutosave: 1,
useThreads: 0, useThreads: 0,
defaultSort: '',
threadAlgorithm: '', threadAlgorithm: '',
replySameFolder: 0, replySameFolder: 0,
hideUnsubscribed: 0, hideUnsubscribed: 0,
@ -96,6 +99,7 @@ export const SettingsUserStore = new class {
'AllowStyles', 'AllowStyles',
'CollapseBlockquotes', 'CollapseBlockquotes',
'MaxBlockquotesLevel', 'MaxBlockquotesLevel',
'minFontSize',
'ListInlineAttachments', 'ListInlineAttachments',
'simpleAttachmentsList', 'simpleAttachmentsList',
'UseCheckboxesInList', 'UseCheckboxesInList',
@ -103,6 +107,7 @@ export const SettingsUserStore = new class {
'showNextMessage', 'showNextMessage',
'AllowDraftAutosave', 'AllowDraftAutosave',
'useThreads', 'useThreads',
'defaultSort',
'threadAlgorithm', 'threadAlgorithm',
'ReplySameFolder', 'ReplySameFolder',
'HideUnsubscribed', 'HideUnsubscribed',
@ -161,5 +166,7 @@ export const SettingsUserStore = new class {
self.messageReadDelay(pInt(SettingsGet('MessageReadDelay'))); self.messageReadDelay(pInt(SettingsGet('MessageReadDelay')));
self.autoLogout(pInt(SettingsGet('AutoLogout'))); self.autoLogout(pInt(SettingsGet('AutoLogout')));
self.keyPassForget(pInt(SettingsGet('keyPassForget'))); self.keyPassForget(pInt(SettingsGet('keyPassForget')));
FolderUserStore.sortMode(self.defaultSort());
} }
}; };

View file

@ -1,6 +1,6 @@
#V-PopupsLanguages { #V-PopupsLanguages {
max-width: 710px; max-width: 720px;
label { label {
display: inline-block; display: inline-block;

View file

@ -125,8 +125,9 @@
/* Right */ /* Right */
.right { .right {
width: 100%;
border-left: 1px solid var(--border-color, #ddd); border-left: 1px solid var(--border-color, #ddd);
max-width: 600px;
width: 100%;
} }
.b-view-content-toolbar { .b-view-content-toolbar {

View file

@ -82,7 +82,7 @@
border-left: 3px solid transparent; border-left: 3px solid transparent;
color: var(--folders-color, #333); color: var(--folders-color, #333);
display: block; display: block;
line-height: 34px; line-height: 2.2em;
padding: 0 2em 0 @folderItemPadding; padding: 0 2em 0 @folderItemPadding;
position: relative; position: relative;
text-decoration: none; text-decoration: none;
@ -151,7 +151,7 @@
color: var(--unread-count-color, #fff); color: var(--unread-count-color, #fff);
font-size: 80%; font-size: 80%;
line-height: 1.5em; line-height: 1.5em;
margin-top: 7px; margin-top: 0.5em;
min-width: 1.7em; min-width: 1.7em;
padding: 1px 4px; padding: 1px 4px;
text-align: center; text-align: center;

View file

@ -45,4 +45,8 @@
content: ')'; content: ')';
} }
.identities-list tr + tr .identity-default {
display: none;
}
} }

View file

@ -15,10 +15,14 @@ export class AskPopupView extends AbstractViewPopup {
askUsername: false, askUsername: false,
passphrase: '', passphrase: '',
askPass: false, askPass: false,
remember: true, remember: true, // remember for session
askRemeber: false askRemeber: false,
rememberPermanent: false,
askRememberPermanent: false,
}); });
this.rememberPermanent.subscribe(value => value && this.remember(true));
this.fYesAction = null; this.fYesAction = null;
this.fNoAction = null; this.fNoAction = null;
@ -48,10 +52,12 @@ export class AskPopupView extends AbstractViewPopup {
this.askDesc(sAskDesc || ''); this.askDesc(sAskDesc || '');
this.askUsername(ask & 2); this.askUsername(ask & 2);
this.askPass(ask & 1); this.askPass(ask & 1);
this.askRemeber(ask & 4); this.askRemeber(ask & 4); // 0b0100
this.askRememberPermanent(ask & 0b1000)
this.username(''); this.username('');
this.passphrase(''); this.passphrase('');
this.remember(true); this.remember(true);
this.rememberPermanent(false);
this.yesButton(i18n(btnText || 'GLOBAL/YES')); this.yesButton(i18n(btnText || 'GLOBAL/YES'));
this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO')); this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO'));
this.fYesAction = fYesFunc; this.fYesAction = fYesFunc;

View file

@ -44,6 +44,7 @@ import Remote from 'Remote/User/Fetch';
import { ComposeAttachmentModel } from 'Model/ComposeAttachment'; import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
import { EmailModel } from 'Model/Email'; import { EmailModel } from 'Model/Email';
import { IdentityModel } from 'Model/Identity';
import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt'; import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
import { addressparser } from 'Mime/Address'; import { addressparser } from 'Mime/Address';
@ -92,7 +93,7 @@ const
findIdentity = addresses => { findIdentity = addresses => {
addresses = addresses.map(item => item.email); addresses = addresses.map(item => item.email);
return IdentityUserStore.find(item => addresses.includes(item.email())); return IdentityUserStore.find(item => addresses.includes(item.email));
}, },
/** /**
@ -343,7 +344,7 @@ export class ComposePopupView extends AbstractViewPopup {
IdentityUserStore.map(item => ({ IdentityUserStore.map(item => ({
item: item, item: item,
optValue: item.id(), optValue: item.id(),
optText: item.formattedName() optText: item
})), })),
canBeSentOrSaved: () => !this.sending() && !this.saving() canBeSentOrSaved: () => !this.sending() && !this.saving()
@ -358,7 +359,7 @@ export class ComposePopupView extends AbstractViewPopup {
currentIdentity: value => { currentIdentity: value => {
if (value) { if (value) {
this.from(value.formattedName()); this.from(value.toString());
this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt()); this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
this.doSign(value.pgpSign() || SettingsUserStore.pgpSign()); this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
} }
@ -454,7 +455,7 @@ export class ComposePopupView extends AbstractViewPopup {
contactsCommand: self => self.allowContacts contactsCommand: self => self.allowContacts
}); });
this.from(IdentityUserStore()[0].formattedName()); this.from(IdentityUserStore()[0].toString());
} }
sentFolder() sentFolder()
@ -862,6 +863,7 @@ export class ComposePopupView extends AbstractViewPopup {
case ComposeType.Forward: case ComposeType.Forward:
case ComposeType.ForwardAsAttachment: case ComposeType.ForwardAsAttachment:
identity = findIdentity(oLastMessage.to.concat(oLastMessage.cc, oLastMessage.bcc)) identity = findIdentity(oLastMessage.to.concat(oLastMessage.cc, oLastMessage.bcc))
|| findIdentity(oLastMessage.from)
/* || findIdentity(oLastMessage.deliveredTo)*/; /* || findIdentity(oLastMessage.deliveredTo)*/;
break; break;
case ComposeType.Draft: case ComposeType.Draft:
@ -871,10 +873,20 @@ export class ComposePopupView extends AbstractViewPopup {
// case ComposeType.Empty: // case ComposeType.Empty:
} }
} }
// Set from custom email
if (!identity
&& oLastMessage && (ComposeType.Reply === options.mode || ComposeType.ReplyAll === options.mode)
&& 1 === oLastMessage.to.length
// && mEmail.includes(oLastMessage.to[0].domain)
) {
identity = new IdentityModel;
identity.name = oLastMessage.to[0].name;
identity.email = oLastMessage.to[0].email;
}
identity = identity || IdentityUserStore()[0]; identity = identity || IdentityUserStore()[0];
if (identity) { if (identity) {
// excludeEmail.add(identity.email()); // excludeEmail.add(identity.email);
excludeEmail[identity.email()] = true; excludeEmail[identity.email] = true;
} }
if (arrayLength(options.to)) { if (arrayLength(options.to)) {
@ -959,7 +971,7 @@ export class ComposePopupView extends AbstractViewPopup {
// https://github.com/the-djmaze/snappymail/issues/491 // https://github.com/the-djmaze/snappymail/issues/491
tpl.innerHTML = oLastMessage.bodyAsHTML(); tpl.innerHTML = oLastMessage.bodyAsHTML();
tpl.content.querySelectorAll('img').forEach(img => { tpl.content.querySelectorAll('img').forEach(img => {
img.src || img.dataset.xSrcCid || img.dataset.xSrc || img.replaceWith(img.alt || img.title) img.src || img.dataset.xSrc || img.replaceWith(img.alt || img.title)
}); });
sText = tpl.innerHTML.trim(); sText = tpl.innerHTML.trim();
@ -1381,7 +1393,7 @@ export class ComposePopupView extends AbstractViewPopup {
allRecipients() { allRecipients() {
return [ return [
// From/sender is also recipient (Sent mailbox) // From/sender is also recipient (Sent mailbox)
// this.currentIdentity().email(), // this.currentIdentity().email,
this.from(), this.from(),
this.to(), this.to(),
this.cc(), this.cc(),
@ -1401,7 +1413,8 @@ export class ComposePopupView extends AbstractViewPopup {
key && options.push(['OpenPGP', key]); key && options.push(['OpenPGP', key]);
key = GnuPGUserStore.getPrivateKeyFor(email, 1); key = GnuPGUserStore.getPrivateKeyFor(email, 1);
key && options.push(['GnuPG', key]); key && options.push(['GnuPG', key]);
identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email() === email identity.smimeKeyValid() && identity.smimeCertificateValid()
&& identity.smimeCertificateChainValid() && identity.email === email
&& options.push(['S/MIME']); && options.push(['S/MIME']);
console.dir({signOptions: options}); console.dir({signOptions: options});
this.signOptions(options); this.signOptions(options);
@ -1420,7 +1433,7 @@ export class ComposePopupView extends AbstractViewPopup {
const count = recipients.length, const count = recipients.length,
identity = this.currentIdentity(), identity = this.currentIdentity(),
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null; from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email : null;
count count
&& count === recipients.filter(email => && count === recipients.filter(email =>
email == from email == from
@ -1497,12 +1510,21 @@ export class ComposePopupView extends AbstractViewPopup {
**/ **/
linkedData: [] linkedData: []
}, },
recipients = draft ? [identity.email()] : this.allRecipients(), recipients = draft ? [identity.email] : this.allRecipients(),
signOptions = !draft && this.doSign() && this.signOptions(), signOptions = !draft && this.doSign() && this.signOptions(),
encryptOptions = this.doEncrypt() && this.encryptOptions(), encryptOptions = this.doEncrypt() && this.encryptOptions(),
isHtml = this.oEditor.isHtml(); isHtml = this.oEditor.isHtml();
if (isHtml) { if (isHtml) {
tpl.innerHTML = Text;
tpl.content.querySelectorAll('img').forEach(img => {
if (img.dataset.xSrc) {
img.src = img.dataset.xSrc;
img.removeAttribute('data-x-src')
}
});
Text = tpl.innerHTML.trim();
do { do {
l = Text.length; l = Text.length;
Text = Text Text = Text
@ -1572,6 +1594,7 @@ export class ComposePopupView extends AbstractViewPopup {
*/ */
break; break;
} catch (e) { } catch (e) {
Passphrases.delete(signOptions[i][1])
console.error(e); console.error(e);
} }
} else if ('GnuPG' == signOptions[i][0]) { } else if ('GnuPG' == signOptions[i][0]) {
@ -1589,11 +1612,12 @@ export class ComposePopupView extends AbstractViewPopup {
// TODO: sign in PHP fails // TODO: sign in PHP fails
params.sign = 'S/MIME'; params.sign = 'S/MIME';
// params.signCertificate = identity.smimeCertificate(); // params.signCertificate = identity.smimeCertificate();
// params.signCertificateChain = identity.smimeCertificateChain();
// params.signPrivateKey = identity.smimeKey(); // params.signPrivateKey = identity.smimeKey();
// params.attachCertificate = false; // params.attachCertificate = false;
if (identity.smimeKeyEncrypted()) { if (identity.smimeKeyEncrypted()) {
const pass = await Passphrases.ask(identity, const pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}), i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
'CRYPTO/SIGN' 'CRYPTO/SIGN'
); );
if (null != pass) { if (null != pass) {
@ -1613,7 +1637,9 @@ export class ComposePopupView extends AbstractViewPopup {
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) => Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
params.autocrypt.push({ params.autocrypt.push({
addr: k, addr: k,
keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim() keydata: v
.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '')
.replace(/\s+/g, '') // Single base64 string, let the server do compliance formatting
}) })
); );
for (let i = 0; i < encryptOptions.length; ++i) { for (let i = 0; i < encryptOptions.length; ++i) {
@ -1633,7 +1659,7 @@ export class ComposePopupView extends AbstractViewPopup {
if ('S/MIME' == encryptOptions[i]) { if ('S/MIME' == encryptOptions[i]) {
params.encryptCertificates = [identity.smimeCertificate()]; params.encryptCertificates = [identity.smimeCertificate()];
SMimeUserStore.forEach(certificate => { SMimeUserStore.forEach(certificate => {
certificate.emailAddress != identity.email() certificate.emailAddress != identity.email
&& recipients.includes(certificate.emailAddress) && recipients.includes(certificate.emailAddress)
&& params.encryptCertificates.push(certificate.id) && params.encryptCertificates.push(certificate.id)
}); });

View file

@ -57,8 +57,8 @@ export class IdentityPopupView extends AbstractViewPopup {
this.submitError(oData.message); this.submitError(oData.message);
} }
}, { }, {
name: identity.name(), name: identity.name,
email: identity.email(), email: identity.email,
privateKey: identity.smimeKey(), privateKey: identity.smimeKey(),
passphrase: result.password passphrase: result.password
}); });

View file

@ -105,9 +105,9 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup {
} }
onShow() { onShow() {
this.name(''/*IdentityUserStore()[0].name()*/); this.name(''/*IdentityUserStore()[0].name*/);
this.password(''); this.password('');
this.email(''/*IdentityUserStore()[0].email()*/); this.email(''/*IdentityUserStore()[0].email*/);
this.emailError(false); this.emailError(false);
this.submitError(''); this.submitError('');
} }

View file

@ -213,15 +213,19 @@ export class MailMessageList extends AbstractViewRight {
sortText: () => { sortText: () => {
let mode = FolderUserStore.sortMode(), let mode = FolderUserStore.sortMode(),
desc = '' === mode || mode.includes('REVERSE'); has = w => mode.includes(w),
desc = '' === mode || has('REVERSE');
mode = mode.split(/\s+/); mode = mode.split(/\s+/);
if (mode.includes('FROM')) { if (has('FROM')) {
return '@' + (desc ? '⬆' : '⬇'); return '@' + (desc ? '⬆' : '⬇');
} }
if (mode.includes('SUBJECT')) { if (has('SUBJECT')) {
return '𝐒' + (desc ? '⬆' : '⬇'); return '𝐒' + (desc ? '⬆' : '⬇');
} }
return (mode.includes('SIZE') ? '✉' : '📅') + (desc ? '⬇' : '⬆'); if (has('SIZE')) {
return '✉' + (desc ? '⬇' : '⬆');
}
return (has('ARRIVAL') ? '📨' : '📅') + (desc ? '⬇' : '⬆');
}, },
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip') downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
@ -299,14 +303,14 @@ export class MailMessageList extends AbstractViewRight {
item => sFolder === item?.folder && iUid == item?.uid item => sFolder === item?.folder && iUid == item?.uid
); );
if ('INBOX' === sFolder) { if ('INBOX' === sFolder && 'INBOX' !== FolderUserStore.currentFolderFullName()) {
hasher.setHash(mailBox(sFolder)); hasher.setHash(mailBox(sFolder));
} }
if (message) { if (message) {
this.selector.selectMessageItem(message); this.selector.selectMessageItem(message);
} else { } else {
if ('INBOX' !== sFolder) { if ('INBOX' !== sFolder && sFolder !== FolderUserStore.currentFolderFullName()) {
hasher.setHash(mailBox(sFolder)); hasher.setHash(mailBox(sFolder));
} }
if (sFolder && iUid) { if (sFolder && iUid) {

View file

@ -56,9 +56,6 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport'; import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { GnuPGUserStore } from 'Stores/User/GnuPG'; import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const const
oMessageScrollerDom = () => elementById('messageItem') || {}, oMessageScrollerDom = () => elementById('messageItem') || {},
@ -133,6 +130,8 @@ export class MailMessageView extends AbstractViewRight {
// viewer // viewer
viewFromShort: '', viewFromShort: '',
dkimData: ['none', '', ''], dkimData: ['none', '', ''],
spfData: ['none', '', ''],
dmarcData: ['none', '', ''],
nowTracking: false nowTracking: false
}); });
@ -197,7 +196,7 @@ export class MailMessageView extends AbstractViewRight {
dkimIcon: () => { dkimIcon: () => {
switch (this.dkimData()[0]) { switch (this.dkimData()[0]) {
case 'none': case 'none':
return ''; return '🚫︎';
case 'pass': case 'pass':
return '✔'; return '✔';
default: default:
@ -205,12 +204,43 @@ export class MailMessageView extends AbstractViewRight {
} }
}, },
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red', dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
dkimTitle:() => { dkimTitle:() => {
const dkim = this.dkimData(); const dkim = this.dkimData();
return dkim[0] ? dkim[2] || 'DKIM: ' + dkim[0] : ''; return dkim[0] ? dkim[2] || 'DKIM: ' + dkim[0] : '';
}, },
spfIcon: () => {
switch (this.spfData()[0]) {
case 'none':
return '🚫︎';
case 'pass':
return '✔';
default:
return '✖';
}
},
spfIconClass: () => 'pass' === this.spfData()[0] ? 'iconcolor-green' : 'iconcolor-red',
spfTitle:() => {
const spf = this.spfData();
return spf[0] ? spf[2] || 'SPF: ' + spf[0] : '';
},
dmarcIcon: () => {
switch (this.dmarcData()[0]) {
case 'none':
return '🚫︎';
case 'pass':
return '✔';
default:
return '✖';
}
},
dmarcIconClass: () => 'pass' === this.dmarcData()[0] ? 'iconcolor-green' : 'iconcolor-red',
dmarcTitle:() => {
const dmarc = this.dmarcData();
return dmarc[0] ? dmarc[2] || 'DMARC: ' + dmarc[0] : '';
},
showWhitelistOptions: () => 'match' === SettingsUserStore.viewImages(), showWhitelistOptions: () => 'match' === SettingsUserStore.viewImages(),
firstUnsubsribeLink: () => currentMessage()?.unsubsribeLinks()[0] || '', firstUnsubsribeLink: () => currentMessage()?.unsubsribeLinks()[0] || '',
@ -233,6 +263,8 @@ export class MailMessageView extends AbstractViewRight {
// TODO: make first param a user setting #683 // TODO: make first param a user setting #683
this.viewFromShort(message.from.toString(false, true)); this.viewFromShort(message.from.toString(false, true));
this.dkimData(message.dkim[0] || ['none', '', '']); this.dkimData(message.dkim[0] || ['none', '', '']);
this.spfData(message.spf[0] || ['none', '', '']);
this.dmarcData(message.dmarc[0] || ['none', '', '']);
this.nowTracking(false); this.nowTracking(false);
} else { } else {
MessagelistUserStore.selectedMessage(null); MessagelistUserStore.selectedMessage(null);
@ -610,125 +642,19 @@ export class MailMessageView extends AbstractViewRight {
} }
pgpDecrypt() { pgpDecrypt() {
const oMessage = currentMessage(), currentMessage().decrypt();
data = oMessage.pgpEncrypted();
delete data.error;
PgpUserStore.decrypt(oMessage).then(result => {
if (!result) {
// TODO: translate
throw Error('Decryption failed, canceled or not possible');
}
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
})
.catch(e => {
data.error = e.message;
})
.finally(() => {
oMessage.pgpEncrypted(data);
});
} }
pgpVerify(/*self, event*/) { pgpVerify(/*self, event*/) {
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/; currentMessage().pgpVerify();
PgpUserStore.verify(oMessage).then(result => {
if (result) {
oMessage.pgpSigned(result);
} else {
alert('Verification failed or no valid public key found');
}
/*
if (result?.success) {
i18n('CRYPTO/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
});
message.getText()
} else {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
: '';
i18n('CRYPTO/ERROR', {
TYPE: 'OpenPGP',
ERROR: 'message'
}) + (additional ? ' (' + additional + ')' : '');
}
*/
});
} }
async smimeDecrypt() { async smimeDecrypt() {
const message = currentMessage(); currentMessage().decrypt();
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
identity = IdentityUserStore.find(item => addresses.includes(item.email())),
data = message.smimeEncrypted(); // { partId: "1" }
if (data && identity) {
delete data.error;
let pass, params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
// params.bodyPart = params.bodyPart?.raw;
params.certificate = identity.smimeCertificate();
params.privateKey = identity.smimeKey();
if (identity.smimeKeyEncrypted()) {
pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
'CRYPTO/DECRYPT'
);
if (!pass) {
return;
}
params.passphrase = pass?.password;
}
Remote.post('SMimeDecryptMessage', null, params).then(response => {
if (response?.Result?.data) {
message.smimeDecrypted(true);
MimeToMessage(response.Result.data, message);
message.html() ? message.viewHtml() : message.viewPlain();
pass && pass.remember && Passphrases.handle(identity, pass.password);
if ('signed' in response.Result) {
message.smimeSigned(response.Result.signed);
}
}
}).catch(e => {
data.error = e.message
})
.finally(() => {
message.smimeEncrypted(data);
});
}
} }
smimeVerify(/*self, event*/) { smimeVerify(/*self, event*/) {
const message = currentMessage(), currentMessage().smimeVerify();
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
if (data) {
const params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
params.bodyPart = data.bodyPart?.raw;
params.sigPart = data.sigPart?.bodyRaw;
Remote.post('SMimeVerifyMessage', null, params).then(response => {
if (response?.Result) {
if (response.Result.body) {
MimeToMessage(response.Result.body, message);
message.html() ? message.viewHtml() : message.viewPlain();
}
data.success = response.Result.success;
message.smimeSigned(data);
}
});
}
} }
} }

View file

@ -77,7 +77,7 @@ Now, login to [http://localhost:8888/?admin](http://localhost:8888/?admin) with
To use PostgreSQL as the DB, in Admin Panel, click `Contacts`, check `Enable contacts` and , and under `Storage (PDO)` choose the following: To use PostgreSQL as the DB, in Admin Panel, click `Contacts`, check `Enable contacts` and , and under `Storage (PDO)` choose the following:
- Type: `PostgresSQL` - Type: `PostgresSQL`
- Data Source Name (DSN): `host=postgres;port=5432;dbname=snappymail` - Data Source Name (DSN): `host=db;port=5432;dbname=snappymail`
- User `snappymail` - User `snappymail`
- Password `snappymail` - Password `snappymail`

View file

@ -63,8 +63,9 @@ There, click on the link to go to the SnappyMail admin panel.
<lib>uuid</lib> <lib>uuid</lib>
<lib>xxtea</lib> <lib>xxtea</lib>
<lib>zip</lib> <lib>zip</lib>
<nextcloud min-version="20" max-version="34" />
--> -->
<nextcloud min-version="20" max-version="30" /> <nextcloud min-version="20" />
</dependencies> </dependencies>
<settings> <settings>
<admin>OCA\SnappyMail\Settings\AdminSettings</admin> <admin>OCA\SnappyMail\Settings\AdminSettings</admin>

View file

@ -176,7 +176,8 @@ class SnappyMailHelper
// If OpenID Connect (OIDC) is enabled and used for login, use this. // If OpenID Connect (OIDC) is enabled and used for login, use this.
if (static::isOIDCLogin()) { if (static::isOIDCLogin()) {
$sEmail = $config->getUserValue($sUID, 'settings', 'email'); $sEmail = $config->getUserValue($sUID, 'settings', 'email');
return [$sUID, $sEmail, "oidc_login|{$sUID}"]; $pwd = new \SnappyMail\SensitiveString("oidc_login|{$sUID}");
return [$sUID, $sEmail, $pwd];
} }
// Only use the user's password in the current session if they have // Only use the user's password in the current session if they have

View file

@ -43,10 +43,11 @@
"dependencies": { "dependencies": {
"@rollup/plugin-node-resolve": "^13.1.3", "@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-replace": "^3.0.1", "@rollup/plugin-replace": "^3.0.1",
"@rollup/plugin-terser": "^1.0.2",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"del": "^6.0.0", "del": "^6.0.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"gulp": "^5.0.0", "gulp": "^5.0.1",
"gulp-append-prepend": "^1.0.9", "gulp-append-prepend": "^1.0.9",
"gulp-cached": "^1.1.1", "gulp-cached": "^1.1.1",
"gulp-clean-css": "^4.3.0", "gulp-clean-css": "^4.3.0",
@ -64,7 +65,6 @@
"gulp-terser": "^2.1.0", "gulp-terser": "^2.1.0",
"rollup": "^2.56.3", "rollup": "^2.56.3",
"rollup-plugin-external-globals": "^0.6.1", "rollup-plugin-external-globals": "^0.6.1",
"rollup-plugin-includepaths": "^0.2.4", "rollup-plugin-includepaths": "^0.2.4"
"rollup-plugin-terser": "^7.0.2"
} }
} }

View file

@ -38,7 +38,10 @@
queue = [], queue = [],
avatars = new Map, avatars = new Map,
ncAvatars = new Map, ncAvatars = new Map,
identicons = new Map,
templateId = 'MailMessageView', templateId = 'MailMessageView',
b64 = data => btoa(unescape(encodeURIComponent(data))),
b64url = data => b64(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
getBimiSelector = msg => { getBimiSelector = msg => {
// Get 's' value out of 'v=BIMI1; s=foo;' // Get 's' value out of 'v=BIMI1; s=foo;'
let bimiSelector = msg.headers().valueByName('BIMI-Selector'); let bimiSelector = msg.headers().valueByName('BIMI-Selector');
@ -46,9 +49,21 @@
return bimiSelector || ''; return bimiSelector || '';
}, },
getBimiId = msg => ('pass' == msg.from[0].dkimStatus ? 1 : 0) + '-' + getBimiSelector(msg), getBimiId = msg => ('pass' == msg.from[0].dkimStatus ? 1 : 0) + '-' + getBimiSelector(msg),
getAvatarUrl = msg => `?Avatar/${getBimiId(msg)}/${msg.avatar}`, email = msg => msg.from[0].email.toLowerCase(),
getAvatarUid = msg => `${getBimiId(msg)}/${msg.from[0].email.toLowerCase()}`, getAvatarUid = msg => `${getBimiId(msg)}/${email(msg)}`,
getAvatar = msg => ncAvatars.get(msg.from[0].email.toLowerCase()) || avatars.get(getAvatarUid(msg)), getAvatar = msg => ncAvatars.get(email(msg)) || avatars.get(getAvatarUid(msg)),
getAvatarUri = msg => {
if ('remote' === msg.avatar) {
msg.avatar = `?Avatar/${getBimiId(msg)}/${b64url(email(msg))}`;
}
/*
else if (!msg.avatar.startsWith('data:')) {
msg.avatar = null;
}
*/
return msg.avatar;
},
hash = async txt => { hash = async txt => {
if (/^[0-9a-f]{15,}$/i.test(txt)) { if (/^[0-9a-f]{15,}$/i.test(txt)) {
return txt; return txt;
@ -64,58 +79,56 @@
(from.name?.split(/[^\p{L}]+/gu) || []).reduce((a, s) => a + (s[0] || ''), '') (from.name?.split(/[^\p{L}]+/gu) || []).reduce((a, s) => a + (s[0] || ''), '')
.slice(0,2) .slice(0,2)
.toUpperCase(), .toUpperCase(),
setIdenticon = (from, fn) => hash(from.email).then(hash => setIdenticon = (msg, fn, cb) => {
fn('data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(window.identiconSvg( const from = email(msg);
hash, if (identicons.get(from)) {
fromChars(from), fn(identicons.get(from));
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family') } else {
))))) hash(from).then(hash => {
), const uri = 'data:image/svg+xml;base64,' + b64(window.identiconSvg(
hash,
fromChars(msg.from[0]),
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family')
));
fn(uri);
identicons.set(email(msg), uri);
cb?.(uri);
});
}
},
addQueue = (msg, fn) => { addQueue = (msg, fn) => {
msg.from?.[0] && setIdenticon(msg.from[0], fn); if (msg.from?.[0]) {
if (rl.pluginSettingsGet('avatars', 'delay')) { if (getAvatarUri(msg)) {
queue.push([msg, fn]); if (rl.pluginSettingsGet('avatars', 'delay')) {
runQueue(); setIdenticon(msg, fn, ()=>{
} else if (msg.avatar) { queue.push([msg, fn]);
fn(getAvatarUrl(msg)); runQueue();
});
} else {
fn(msg.avatar);
}
} else {
setIdenticon(msg, fn);
}
} }
}, },
runQueue = (() => { runQueue = (() => {
let item = queue.shift(); let item = queue.shift();
while (item) { while (item) {
if (item[0].from) { let url = getAvatar(item[0]);
let url = getAvatar(item[0]), if (url) {
uid = getAvatarUid(item[0]); item[1](url);
if (url) { item = queue.shift();
item[1](url); continue;
item = queue.shift(); } else if (item[0].avatar) {
continue; item[1](item[0].avatar);
} else if (item[0].avatar) {
item[1](getAvatarUrl(item[0]));
} else if (!avatars.has(uid)) {
let from = item[0].from[0];
rl.pluginRemoteRequest((iError, data) => {
if (!iError && data?.Result.type) {
url = `data:${data.Result.type};base64,${data.Result.data}`;
avatars.set(uid, url);
item[1](url);
} else {
avatars.set(uid, '');
}
runQueue();
}, 'Avatar', {
bimi: 'pass' == from.dkimStatus ? 1 : 0,
bimiSelector: getBimiSelector(item[0]),
email: from.email
});
}
} }
runQueue(); runQueue();
break; break;
} }
}).debounce(1000); }).debounce(1000);
// addEventListener('DOMContentLoaded', () => {
/** /**
* Modify templates * Modify templates
*/ */
@ -188,13 +201,19 @@
fn = url=>{element.src = url}; fn = url=>{element.src = url};
element.onerror = ()=>{ element.onerror = ()=>{
element.onerror = null; element.onerror = null;
setIdenticon(msg.from[0], fn); setIdenticon(msg, fn, uri=>avatars.set(getAvatarUid(msg), uri));
}; };
if (url) { if (url) {
fn(url); fn(url);
} else if (msg.avatar?.startsWith('data:')) { } else if (msg.avatar?.startsWith('data:')) {
fn(msg.avatar); fn(msg.avatar);
} else { } else {
element.onload = ()=>{
if (!element.src.startsWith('data:')) {
element.onload = null;
avatars.set(getAvatarUid(msg), element.src);
}
};
addQueue(msg, fn); addQueue(msg, fn);
} }
} }
@ -221,11 +240,8 @@
if (url) { if (url) {
fn(url); fn(url);
} else if (msg.avatar) { } else if (msg.avatar) {
fn(msg.avatar.startsWith('data:') ? msg.avatar : getAvatarUrl(msg)); fn(getAvatarUri(msg));
} else { } else {
// let from = msg.from[0];
// view.viewUserPic(`?Avatar/${'pass' == from.dkimStatus ? 1 : 0}/${encodeURIComponent(from.email)}`);
// view.viewUserPicVisible(true);
addQueue(msg, fn); addQueue(msg, fn);
} }
} }

View file

@ -10,12 +10,12 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Avatars', NAME = 'Avatars',
AUTHOR = 'SnappyMail', AUTHOR = 'SnappyMail',
URL = 'https://snappymail.eu/', URL = 'https://snappymail.eu/',
VERSION = '1.20', VERSION = '1.22',
RELEASE = '2024-08-26', RELEASE = '2025-03-10',
REQUIRED = '2.33.0', REQUIRED = '2.33.0',
CATEGORY = 'Contacts', CATEGORY = 'Contacts',
LICENSE = 'MIT', LICENSE = 'MIT',
DESCRIPTION = 'Show graphic of sender in message and messages list (supports BIMI, Gravatar, favicon and identicon, Contacts is still TODO)'; DESCRIPTION = 'Show graphic of sender in message and messages list (supports Contacts, BIMI, Gravatar, favicon and identicon)';
public function Init() : void public function Init() : void
{ {
@ -79,11 +79,8 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|| ($this->Config()->Get('plugin', 'bimi', false) && 'pass' == $mFrom['dkimStatus']) || ($this->Config()->Get('plugin', 'bimi', false) && 'pass' == $mFrom['dkimStatus'])
|| ($this->Config()->Get('plugin', 'favicon', false) && 'pass' == $mFrom['dkimStatus']) || ($this->Config()->Get('plugin', 'favicon', false) && 'pass' == $mFrom['dkimStatus'])
) )
) try { ) {
// Base64Url return 'remote';
return \SnappyMail\Crypt::EncryptUrlSafe($mFrom['email']);
} catch (\Throwable $e) {
\SnappyMail\Log::error('Crypt', $e->getMessage());
} }
if ('pass' == $mFrom['dkimStatus'] && $this->Config()->Get('plugin', 'service', true)) { if ('pass' == $mFrom['dkimStatus'] && $this->Config()->Get('plugin', 'service', true)) {
// 'data:image/png;base64,[a-zA-Z0-9+/=]' // 'data:image/png;base64,[a-zA-Z0-9+/=]'
@ -112,17 +109,17 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
} }
/** /**
* GET /?Avatar/${bimi}/Encrypted(${from.email}) * GET /?Avatar/${bimi}/Encoded(${from.email})
* Nextcloud Mail uses insecure unencrypted 'index.php/apps/mail/api/avatars/url/local%40example.com' * Nextcloud Mail uses insecure unencoded 'index.php/apps/mail/api/avatars/url/local%40example.com'
*/ */
// public function ServiceAvatar(...$aParts) // public function ServiceAvatar(...$aParts)
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncryptedEmail) public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncodedEmail)
{ {
$maxAge = 86400; $maxAge = 86400;
$sEmail = \SnappyMail\Crypt::DecryptUrlSafe($sEncryptedEmail); $sEmail = \MailSo\Base\Utils::UrlSafeBase64Decode($sEncodedEmail);
$aBimi = \explode('-', $sBimi, 2); $aBimi = \explode('-', $sBimi, 2);
$sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default'; $sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default';
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncryptedEmail}"); // $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncodedEmail}");
if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($aBimi[0]), $sBimiSelector))) { if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($aBimi[0]), $sBimiSelector))) {
\header("Cache-Control: max-age={$maxAge}, private"); \header("Cache-Control: max-age={$maxAge}, private");
\header('Expires: '.\gmdate('D, j M Y H:i:s', $maxAge + \time()).' UTC'); \header('Expires: '.\gmdate('D, j M Y H:i:s', $maxAge + \time()).' UTC');
@ -142,6 +139,10 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetAllowedInJs(true) ->SetAllowedInJs(true)
->SetDefaultValue(true), ->SetDefaultValue(true),
\RainLoop\Plugins\Property::NewInstance('contacts')->SetLabel('Contacts')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDescription('Use the PHOTO stored on a matching address book contact')
->SetDefaultValue(true),
\RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('BIMI') \RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('BIMI')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false) ->SetDefaultValue(false)
@ -188,11 +189,8 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
return $aResult; return $aResult;
} }
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid. private static function getServicePng(string $sDomain) : ?string
private static function getServiceIcon(string $sEmail) : ?string
{ {
$sDomain = \explode('@', $sEmail);
$sDomain = \array_pop($sDomain);
$aServices = [ $aServices = [
"services/{$sDomain}", "services/{$sDomain}",
'services/' . static::serviceDomain($sDomain) 'services/' . static::serviceDomain($sDomain)
@ -200,9 +198,20 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
foreach ($aServices as $service) { foreach ($aServices as $service) {
$file = __DIR__ . "/images/{$service}.png"; $file = __DIR__ . "/images/{$service}.png";
if (\file_exists($file)) { if (\file_exists($file)) {
return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file)); return $file;
} }
} }
return null;
}
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
private static function getServiceIcon(string $sEmail) : ?string
{
$aParts = \explode('@', $sEmail);
$file = static::getServicePng(\array_pop($aParts));
if ($file) {
return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file));
}
$aResult = static::getCachedImage($sEmail); $aResult = static::getCachedImage($sEmail);
if ($aResult) { if ($aResult) {
@ -225,30 +234,23 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
\header('Cache-Control: private'); \header('Cache-Control: private');
// \header('Expires: '.\gmdate('D, j M Y H:i:s', \time() + 86400).' UTC'); // \header('Expires: '.\gmdate('D, j M Y H:i:s', \time() + 86400).' UTC');
// A photo stored on the contact is authoritative, so it is consulted
// before the cache and before any network lookup: it beats a guessed
// avatar and replaces up to five remote round-trips. Deliberately not
// written to the avatars cache, which has no expiry - a cached copy
// would shadow later edits to the contact.
if ($this->Config()->Get('plugin', 'contacts', true)) {
$aResult = static::getContactPhoto($sAsciiEmail);
if ($aResult) {
return $aResult;
}
}
$aResult = static::getCachedImage($sEmail); $aResult = static::getCachedImage($sEmail);
if ($aResult) { if ($aResult) {
return $aResult; return $aResult;
} }
// TODO: lookup contacts vCard and return PHOTO value
/*
if (!$aResult) {
$oActions = \RainLoop\Api::Actions();
$oAccount = $oActions->getAccountFromToken();
if ($oAccount) {
$oAddressBookProvider = $oActions->AddressBookProvider($oAccount);
if ($oAddressBookProvider) {
$oContact = $oAddressBookProvider->GetContactByEmail($sEmail);
if ($oContact && $oContact->vCard && $oContact->vCard['PHOTO']) {
$aResult = [
'text/vcard',
$oContact->vCard
];
}
}
}
}
*/
if (!$aResult) { if (!$aResult) {
$sDomain = \explode('@', $sEmail); $sDomain = \explode('@', $sEmail);
@ -268,7 +270,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
} }
if ($this->Config()->Get('plugin', 'gravatar', false)) { if ($this->Config()->Get('plugin', 'gravatar', false)) {
$aUrls[] = 'https://gravatar.com/avatar/'.\md5(\strtolower($sAsciiEmail)).'?s=80&d=404'; $aUrls[] = 'https://gravatar.com/avatar/'.\hash('sha256', \strtolower($sAsciiEmail)).'?s=80&d=404';
} }
foreach ($aUrls as $sUrl) { foreach ($aUrls as $sUrl) {
@ -284,20 +286,13 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid. // Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
if ($bBimi && !$aResult) { if ($bBimi && !$aResult) {
$aServices = [ $file = static::getServicePng($sDomain);
"services/{$sDomain}", if ($file) {
'services/' . static::serviceDomain($sDomain) \MailSo\Base\Http::setLastModified(\filemtime($file));
]; $aResult = [
foreach ($aServices as $service) { 'image/png',
$file = __DIR__ . "/images/{$service}.png"; \file_get_contents($file)
if (\file_exists($file)) { ];
\MailSo\Base\Http::setLastModified(\filemtime($file));
$aResult = [
'image/png',
\file_get_contents($file)
];
break;
}
} }
if (!$aResult && $this->Config()->Get('plugin', 'favicon', false)) { if (!$aResult && $this->Config()->Get('plugin', 'favicon', false)) {
@ -318,6 +313,77 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
return $sDomain; return $sDomain;
} }
/**
* Return the PHOTO of the address book contact owning this address.
*
* vCard 4.0 stores it as a data: URI, vCard 3.0 as ENCODING=b with a TYPE
* parameter, and either version may point at an external URL instead.
*/
private static function getContactPhoto(string $sEmail) : ?array
{
try {
$oActions = \RainLoop\Api::Actions();
$oAccount = $oActions->getAccountFromToken(false);
if (!$oAccount) {
return null;
}
$oProvider = $oActions->AddressBookProvider($oAccount);
if (!$oProvider || !$oProvider->IsActive()) {
return null;
}
// Several contacts can share an address - a synced card, a manual
// entry, an auto-collected one - and only one of them may carry the
// photo. GetContactByEmail() returns whichever matches first, so
// fall back to scanning the matches for one that actually has it.
$oPhoto = null;
$oContact = $oProvider->GetContactByEmail($sEmail);
if ($oContact && $oContact->vCard && isset($oContact->vCard->PHOTO)) {
$oPhoto = $oContact->vCard->PHOTO;
} else {
$iCount = 0;
foreach ($oProvider->GetContacts(0, 20, $sEmail, $iCount) as $oCandidate) {
if ($oCandidate->vCard && isset($oCandidate->vCard->PHOTO)) {
$oPhoto = $oCandidate->vCard->PHOTO;
break;
}
}
}
if (!$oPhoto) {
return null;
}
$sValue = \trim((string) $oPhoto);
if (!\strlen($sValue)) {
return null;
}
// vCard 4.0: data:image/jpeg;base64,...
if (\preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#si', $sValue, $aMatch)) {
$sBinary = \base64_decode(\preg_replace('#\s+#', '', $aMatch[2]), true);
return $sBinary ? [\strtolower($aMatch[1]), $sBinary] : null;
}
// vCard 3.0: PHOTO;ENCODING=b;TYPE=JPEG:<base64>
$sEncoding = \strtolower((string) ($oPhoto['ENCODING'] ?? ''));
if ('b' === $sEncoding || 'base64' === $sEncoding) {
$sBinary = \base64_decode(\preg_replace('#\s+#', '', $sValue), true);
if ($sBinary) {
$sType = \strtolower((string) ($oPhoto['TYPE'] ?? '')) ?: 'jpeg';
return [\str_starts_with($sType, 'image/') ? $sType : "image/{$sType}", $sBinary];
}
return null;
}
// Either version may reference the image instead of embedding it.
if (\preg_match('#^https?://#i', $sValue)) {
return static::getUrl($sValue);
}
} catch (\Throwable $oException) {
\SnappyMail\Log::notice('Avatar', 'contact photo lookup failed: ' . $oException->getMessage());
}
return null;
}
private static function cacheImage(string $sEmail, array $aResult) : void private static function cacheImage(string $sEmail, array $aResult) : void
{ {
if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) { if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) {

View file

@ -0,0 +1,13 @@
[SETTINGS_CHANGE_PASSWORD]
LEGEND_CHANGE_PASSWORD = "Змінити пароль"
LABEL_CURRENT_PASSWORD = "Поточний пароль"
LABEL_NEW_PASSWORD = "Новий пароль"
LABEL_REPEAT_PASSWORD = "Підтвердьте новий пароль"
BUTTON_UPDATE_PASSWORD = "Змінити"
ERROR_PASSWORD_MISMATCH = "Паролі не збігаються, спробуйте ще раз"
[NOTIFICATIONS]
COULD_NOT_SAVE_NEW_PASSWORD = "Не вдалося зберегти новий пароль"
CURRENT_PASSWORD_INCORRECT = "Поточний пароль не вірний"
NEW_PASSWORD_SHORT = "Пароль закороткий"
NEW_PASSWORD_WEAK = "Пароль надто легкий"
NEW_PASSWORD_HIBP = "Пароль знайдено в сервісі Have I Been Pwned"

View file

@ -6,7 +6,7 @@ class CompactComposerPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Compact Composer', NAME = 'Compact Composer',
AUTHOR = 'Sergey Mosin', AUTHOR = 'Sergey Mosin',
URL = 'https://github.com/the-djmaze/snappymail/pull/1466', URL = 'https://github.com/the-djmaze/snappymail/pull/1466',
VERSION = '1.0.5', VERSION = '1.0.6',
RELEASE = '2024-08-08', RELEASE = '2024-08-08',
REQUIRED = '2.34.0', REQUIRED = '2.34.0',
LICENSE = 'AGPL v3', LICENSE = 'AGPL v3',

View file

@ -100,10 +100,6 @@
} }
}, },
pasteSanitizer = (event) => {
return rl.Utils.cleanHtml(event.detail.html).html;
},
pasteImageHandler = (e, squire) => { pasteImageHandler = (e, squire) => {
const items = [...e.detail.clipboardData.items]; const items = [...e.detail.clipboardData.items];
@ -142,7 +138,12 @@
toolbar.className = 'squire-toolbar btn-toolbar'; toolbar.className = 'squire-toolbar btn-toolbar';
const actions = this.makeActions(squire, toolbar); const actions = this.makeActions(squire, toolbar);
this.squire.addEventListener('willPaste', pasteSanitizer); this.squire.addEventListener('willPaste', (event) => {
// https://github.com/fastmail/Squire?tab=readme-ov-file#addeventlistener
// The content that will be inserted is available as either the fragment property, or the text property for plain text, on the detail property of the event. You can modify this text/fragment in your event handler to change what will be pasted
tpl.innerHTML = rl.Utils.cleanHtml(event.detail.html).html;
event.detail.fragment = tpl.content;
});
this.squire.addEventListener('pasteImage', (e) => { this.squire.addEventListener('pasteImage', (e) => {
pasteImageHandler(e, squire); pasteImageHandler(e, squire);
}); });

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@ class ICSViewerPlugin extends \RainLoop\Plugins\AbstractPlugin
const const
NAME = 'ICS Viewer', NAME = 'ICS Viewer',
AUTHOR = 'PhieF', AUTHOR = 'PhieF',
VERSION = '1.0', VERSION = '1.1',
RELEASE = '2024-09-17', RELEASE = '2026-10-22',
CATEGORY = 'Messages', CATEGORY = 'Messages',
DESCRIPTION = 'Display ICS attachment using ical lib, or JSON-LD details, based on viewICS', DESCRIPTION = 'Display ICS attachment using ical lib, or JSON-LD details, based on viewICS',
REQUIRED = '2.34.0'; REQUIRED = '2.34.0';

View file

@ -86,10 +86,17 @@
}); });
} }
}); });
// ICS attachment // ICS attachment.
// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType); // attachments is a ko.observableArray, so it must be
// unwrapped before array methods are used on it.
let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType); // A scheduling mail does not always carry a text/calendar
// attachment either: the part is often inline inside
// multipart/alternative and repeated as application/ics.
let ics = (msg.attachments() || []).find(attachment =>
'text/calendar' == attachment.mimeType
|| 'application/ics' == attachment.mimeType
|| 'text/x-vcalendar' == attachment.mimeType
|| /\.ics$/i.test(attachment.fileName || ''));
if (ics && ics.download) { if (ics && ics.download) {
// fetch it and parse the VEVENT // fetch it and parse the VEVENT

View file

@ -139,29 +139,41 @@ class LdapContactsSuggestions implements \RainLoop\Providers\Suggestions\ISugges
$sFilter .= (1 < count($aItems) ? '(|' : '').$sSubFilter.(1 < count($aItems) ? ')' : ''); $sFilter .= (1 < count($aItems) ? '(|' : '').$sSubFilter.(1 < count($aItems) ? ')' : '');
$sFilter .= ')'; $sFilter .= ')';
$this->logWrite('ldap_search: start: '.$sBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP'); // A directory rarely keeps everything worth suggesting in one branch:
$oS = @\ldap_search($oCon, $sBaseDn, $sFilter, $aItems, 0, 30, 30); // meeting rooms and other bookable resources commonly live outside
if ($oS) { // the people branch. Searching a single subtree either misses them,
$aEntries = @\ldap_get_entries($oCon, $oS); // or - if the base is widened to the domain root - drags every
if (is_array($aEntries)) { // service account into the suggestion list. Base DNs are therefore
if (isset($aEntries['count'])) { // separated by '|', which cannot appear unescaped in a DN, so an
unset($aEntries['count']); // existing single-branch configuration keeps working unchanged.
} $aBaseDns = \array_filter(\array_map('trim', \explode('|', $sBaseDn)), 'strlen');
foreach ($aEntries as $aItem) { foreach ($aBaseDns as $sOneBaseDn) {
if ($aItem) { $this->logWrite('ldap_search: start: '.$sOneBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP');
$sName = $sEmail = ''; $oS = @\ldap_search($oCon, $sOneBaseDn, $sFilter, $aItems, 0, 30, 30);
list ($sEmail, $sName) = $this->findNameAndEmail($aItem, $aEmails, $aNames, $aUIDs); if ($oS) {
if (!empty($sEmail)) { $aEntries = @\ldap_get_entries($oCon, $oS);
$aResult[] = array($sEmail, $sName); if (is_array($aEntries)) {
if (isset($aEntries['count'])) {
unset($aEntries['count']);
}
foreach ($aEntries as $aItem) {
if ($aItem) {
$sName = $sEmail = '';
list ($sEmail, $sName) = $this->findNameAndEmail($aItem, $aEmails, $aNames, $aUIDs);
if (!empty($sEmail)) {
$aResult[] = array($sEmail, $sName);
}
} }
} }
} else {
$this->logLdapError($oCon, 'ldap_get_entries');
} }
} else { } else {
$this->logLdapError($oCon, 'ldap_get_entries'); // One unreachable branch must not silence the others.
$this->logLdapError($oCon, 'ldap_search ('.$sOneBaseDn.')');
} }
} else {
$this->logLdapError($oCon, 'ldap_search');
} }
} }

View file

@ -4,8 +4,8 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
{ {
const const
NAME = 'Contacts suggestions (LDAP)', NAME = 'Contacts suggestions (LDAP)',
VERSION = '2.14', VERSION = '2.15',
RELEASE = '2024-03-12', RELEASE = '2026-08-19',
REQUIRED = '2.35.3', REQUIRED = '2.35.3',
CATEGORY = 'Contacts', CATEGORY = 'Contacts',
DESCRIPTION = 'Get contacts suggestions from LDAP.'; DESCRIPTION = 'Get contacts suggestions from LDAP.';
@ -79,7 +79,7 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetType(\RainLoop\Enumerations\PluginPropertyType::PASSWORD) ->SetType(\RainLoop\Enumerations\PluginPropertyType::PASSWORD)
->SetDefaultValue(''), ->SetDefaultValue(''),
\RainLoop\Plugins\Property::NewInstance('base_dn')->SetLabel('Search base DN') \RainLoop\Plugins\Property::NewInstance('base_dn')->SetLabel('Search base DN')
->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port}') ->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port} Several branches may be given, separated by | - useful when meeting rooms or other resources live outside the people branch.')
->SetDefaultValue('ou=People,dc=example,dc=com'), ->SetDefaultValue('ou=People,dc=example,dc=com'),
\RainLoop\Plugins\Property::NewInstance('object_classes')->SetLabel('objectClasses') \RainLoop\Plugins\Property::NewInstance('object_classes')->SetLabel('objectClasses')
->SetDescription('LDAP objectClasses to search for, comma separated list') ->SetDescription('LDAP objectClasses to search for, comma separated list')

View file

@ -1 +1 @@
Plugin which allows you to set up LDAP username by email address (when IMAP/SMTP requires it) This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication.

View file

@ -8,12 +8,12 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
{ {
const const
NAME = 'LDAP login mapping', NAME = 'LDAP login mapping',
VERSION = '2.3', VERSION = '2.4',
AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>', AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>',
RELEASE = '2024-09-20', RELEASE = '2025-01-09',
REQUIRED = '2.36.1', REQUIRED = '2.36.1',
CATEGORY = 'Login', CATEGORY = 'Login',
DESCRIPTION = 'Enable custom mapping using ldap field'; DESCRIPTION = 'This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication';
/** /**
* @var array * @var array
*/ */
@ -104,9 +104,10 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
$sResult = $this->ldapSearch($sEmail); $sResult = $this->ldapSearch($sEmail);
if ( is_array($sResult) ) { if ( is_array($sResult) ) {
$sImapUser = $sResult['login']; $sImapUser = $sResult['login'];
$sSmtpUser = $sResult['login'];
$sEmail = $sResult['email']; $sEmail = $sResult['email'];
} }
syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginСredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail"); syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginCredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail");
} }
} }

View file

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2016 RainLoop Team
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,39 @@
<?php
class LoginHttpPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Login HTTP',
AUTHOR = 'Alterative Editions',
URL = 'https://alterative.fr/',
VERSION = '1.0',
RELEASE = '2026-02-09',
REQUIRED = '2.21.0',
CATEGORY = 'Login',
LICENSE = 'MIT',
DESCRIPTION = 'Automatic HTTP Login : uses PHP_AUTH_USER & PHP_AUTH_PW for IMAP connection';
public function Init() : void
{
$this->addPartHook('ExternalHTTP', 'ServiceExternalHTTP');
}
public function ServiceExternalHTTP() : string
{
$oActions = \RainLoop\Api::Actions();
$oActions->Http()->ServerNoCache();
$sUser=$_SERVER['PHP_AUTH_USER'] ?? '';
$sPassword=$_SERVER['PHP_AUTH_PW'] ?? '';
try {
$oAccount = $oActions->LoginProcess($sUser, new \SnappyMail\SensitiveString($sPassword));
if ($oAccount) {
\MailSo\Base\Http::Location('./');
return true;
}
} catch (\Throwable $e) {
// Log l'erreur si besoin
}
\MailSo\Base\Http::Location('./?ExternalHTTP');
return true;
}
}

View file

@ -1,54 +1,91 @@
(rl => { ((rl) => {
const client_id = rl.pluginSettingsGet('login-o365', 'client_id'), const client_id = rl.pluginSettingsGet("login-o365", "client_id"),
// https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris allowAnyDomain = !!rl.pluginSettingsGet("login-o365", "allow_any_domain"),
query = rl.pluginSettingsGet('login-o365', 'personal') ? '' : '?', isSupportedEmail = (email) => {
tenant = rl.pluginSettingsGet('login-o365', 'tenant'), email = (email || "").toLowerCase();
login = () => { if (!email.includes("@")) return false;
document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({ if (allowAnyDomain) return true;
response_type: 'code', return /@(outlook\.com|hotmail\.com|live\.com)$/.test(email);
client_id: client_id, },
redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365', startOAuth = (op, opts = {}) => {
scope: [ const email = (opts.email || "").toLowerCase();
// Associate personal info const name = opts.name || "";
'openid', const returnHash = opts.return || "";
'offline_access',
'email',
'profile',
// Access IMAP and SMTP through OAUTH
'https://graph.microsoft.com/IMAP.AccessAsUser.All',
// 'https://graph.microsoft.com/Mail.ReadWrite'
'https://graph.microsoft.com/Mail.Send'
/* // Legacy:
'https://outlook.office.com/SMTP.Send',
'https://outlook.office.com/IMAP.AccessAsUser.All'
*/
].join(' '),
state: 'o365', // + rl.settings.app('token') + localStorage.getItem('smctoken')
// Force authorize screen, so we always get a refresh_token
access_type: 'offline',
prompt: 'consent'
}));
};
if (client_id) { // Server mints a signed state + correct redirect_uri and returns full authUrl.
addEventListener('sm-user-login', e => { rl.pluginRemoteRequest((iError, data) => {
if (event.detail.get('Email').includes('@hotmail.com')) { const url = data?.Result?.authUrl;
e.preventDefault(); if (!iError && url) {
login(); document.location = url;
} }
}); }, "LoginO365AuthUrl", {
op: op,
email: email,
name: name,
return: returnHash,
});
};
addEventListener('rl-view-model', e => { if (client_id) {
if ('Login' === e.detail.viewModelTemplateID) { addEventListener("sm-user-login", (e) => {
const const email = (e.detail.get("Email") || "").toLowerCase();
container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'), if (isSupportedEmail(email)) {
btn = Element.fromHTML('<button type="button">Outlook</button>'), e.preventDefault();
div = Element.fromHTML('<div class="controls"></div>'); startOAuth("login", { email });
btn.onclick = login; }
div.append(btn); });
container && container.append(div);
}
});
}
addEventListener("rl-view-model", (e) => {
if ("Login" === e.detail.viewModelTemplateID) {
const
container = e.detail.viewModelDom.querySelector("#plugin-Login-BottomControlGroup"),
btn = Element.fromHTML('<button type="button">Outlook</button>'),
div = Element.fromHTML('<div class="controls"></div>');
btn.onclick = () => {
// Best-effort: try to read the email field if present.
const input = e.detail.viewModelDom.querySelector('input[type="email"], input[name="Email"], input[name="email"], input');
const email = (input?.value || "").toLowerCase();
if (!email || isSupportedEmail(email)) {
startOAuth("login", { email });
}
};
div.append(btn);
container && container.append(div);
}
// "Add account" popup (Settings → Accounts → Add account)
if ("PopupsAccount" === e.detail.viewModelTemplateID) {
// Only for the "Add account" mode, not "Edit account".
if (typeof e.detail.isNew === "function" && !e.detail.isNew()) {
return;
}
const root = e.detail.viewModelDom;
if (!root) return;
const footer = root.querySelector("footer");
const form = root.querySelector("#accountform");
const addButton = root.querySelector("button.buttonAddAccount");
if (!footer || !form || !addButton) return;
// Avoid inserting duplicates when view model is re-rendered.
if (root.querySelector(".plugin-o365-add-account")) return;
const btn = Element.fromHTML(
'<button type="button" class="btn plugin-o365-add-account" style="margin-left: 6px;">Outlook</button>'
);
btn.onclick = () => {
const email = (form.querySelector('input[name="email"]')?.value || "").trim().toLowerCase();
const name = (form.querySelector('input[name="name"]')?.value || "").trim();
if (!email || !isSupportedEmail(email)) {
return;
}
startOAuth("add", { email, name, return: document.location.hash || "#/settings/accounts" });
};
// Put the button next to the default Add Account submit button.
footer.insertBefore(btn, addButton.nextSibling);
}
});
}
})(window.rl); })(window.rl);

View file

@ -1,15 +1,20 @@
<?php <?php
/** /**
* Microsoft requires an Azure account that has an active subscription * SnappyMail login-o365 plugin
* I'm not going to pay, so feel free to fix this code yourself. * You need to register an app in Azure portal and add
* https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth * a secret, redirect URIs and the following API permissions:
* https://answers.microsoft.com/en-us/msoffice/forum/all/configuration-for-imap-pop-and-smtp-with-oauth-in/3db47d43-25ac-4e0b-b957-22585e6caf15 * https://outlook.office.com/IMAP.AccessAsUser.All
* * https://outlook.office.com/SMTP.Send
* https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps * openid offline_access email profile
*
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris * https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365 * Query: redirect_uri=https://{DOMAIN}/?LoginO365
* Personal: redirect_uri=https://{DOMAIN}/LoginO365 * Path: redirect_uri=https://{DOMAIN}/LoginO365
*
* If running behind nginx reverse proxy you might
* need to add the following to your nginx config:
* location = /LoginO365 {
* return 302 /?LoginO365&$args;
* }
*/ */
use RainLoop\Model\MainAccount; use RainLoop\Model\MainAccount;
@ -17,232 +22,674 @@ use RainLoop\Providers\Storage\Enumerations\StorageType;
class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
{ {
const const
NAME = 'Office365/Outlook OAuth2', NAME = 'Office365/Outlook OAuth2',
VERSION = '0.3', VERSION = '0.4',
RELEASE = '2024-09-29', RELEASE = '2025-12-22',
REQUIRED = '2.36.1', REQUIRED = '2.36.1',
CATEGORY = 'Login', CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2'; DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
// https://login.microsoftonline.com/{{tenant}}/v2.0/.well-known/openid-configuration // v2 endpoints
const const
LOGIN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/auth', AUTH_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/authorize',
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token'; TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
private static ?array $auth = null; /**
* In-request cache of decrypted token bundles, keyed by lowercase email.
* This avoids re-decrypting the same blob multiple times during a single request.
*
* Shape:
* [
* 'user@outlook.com' => ['access_token'=>..., 'refresh_token'=>..., 'expires'=>..., 'expires_in'=>...],
* ...
* ]
*/
private static array $auth = [];
public function Init() : void public function Init() : void
{ {
$this->UseLangs(true); $this->UseLangs(true);
$this->addJs('LoginOAuth2.js'); $this->addJs('LoginOAuth2.js');
$this->addHook('imap.before-login', 'clientLogin'); $this->addHook('imap.before-login', 'clientLogin');
$this->addHook('smtp.before-login', 'clientLogin'); $this->addHook('smtp.before-login', 'clientLogin');
$this->addHook('sieve.before-login', 'clientLogin'); $this->addHook('sieve.before-login', 'clientLogin');
$this->addPartHook('LoginO365', 'ServiceLoginO365'); $this->addPartHook('LoginO365', 'ServiceLoginO365');
// Used by JS to obtain an auth URL with signed state (for both login + add-account flows).
$this->addJsonHook('LoginO365AuthUrl', 'DoLoginO365AuthUrl');
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true // Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
$this->addHook('filter.http-paths', 'httpPaths'); $this->addHook('filter.http-paths', 'httpPaths');
}
public function httpPaths(array &$aPaths) : void // Cleanup: when an additional account is removed, also remove its encrypted refresh token bundle.
{ $this->addHook('json.after-AccountDelete', 'afterAccountDelete');
// Personal accounts workaround }
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
$aPaths = ['LoginO365'];
}
if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) { public function httpPaths(array &$aPaths) : void
$oConfig = \RainLoop\Api::Config(); {
$oConfig->Set('security', 'secfetch_allow', if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
\trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';') $aPaths = ['LoginO365'];
); }
}
}
public function ServiceLoginO365() : string if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) {
{ $oConfig = \RainLoop\Api::Config();
$oActions = \RainLoop\Api::Actions(); $oConfig->Set('security', 'secfetch_allow',
$oHttp = $oActions->Http(); \trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';')
$oHttp->ServerNoCache(); );
}
}
try public function ServiceLoginO365() : string
{
$oActions = \RainLoop\Api::Actions();
$oHttp = $oActions->Http();
$oHttp->ServerNoCache();
try
{ {
if (isset($_GET['error'])) { if (isset($_GET['error'])) {
throw new \RuntimeException("{$_GET['error']}: {$_GET['error_description']}"); $desc = $_GET['error_description'] ?? '';
} throw new \RuntimeException("{$_GET['error']}: {$desc}");
if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) { }
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$oO365 = $this->o365Connector();
if (!$oO365) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$iExpires = \time(); // Must have code + state
$aResponse = $oO365->getAccessToken( if (!isset($_GET['code']) || empty($_GET['state'])) {
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI), $oActions->Location(\RainLoop\Utils::WebPath());
'authorization_code', exit;
array( }
'code' => $_GET['code'],
'redirect_uri' => $oHttp->GetFullUrl().'?LoginO365'
)
);
if (200 != $aResponse['code']) {
if (isset($aResponse['result']['error'])) {
throw new \RuntimeException(
$aResponse['code']
. ': '
. $aResponse['result']['error']
. ' / '
. $aResponse['result']['error_description']
);
}
throw new \RuntimeException("HTTP: {$aResponse['code']}");
}
$aResponse = $aResponse['result'];
if (empty($aResponse['access_token'])) {
throw new \RuntimeException('access_token missing');
}
if (empty($aResponse['refresh_token'])) {
throw new \RuntimeException('refresh_token missing');
}
$sAccessToken = $aResponse['access_token']; $oO365 = $this->o365Connector();
$iExpires += $aResponse['expires_in']; if (!$oO365) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$oO365->setAccessToken($sAccessToken); $iNow = \time();
$aUserInfo = $oO365->fetch('https://graph.microsoft.com/oidc/userinfo');
if (200 != $aUserInfo['code']) {
throw new \RuntimeException("HTTP: {$aResponse['code']}");
}
$aUserInfo = $aUserInfo['result'];
if (empty($aUserInfo['id'])) {
throw new \RuntimeException('unknown id');
}
if (empty($aUserInfo['email'])) {
throw new \RuntimeException('unknown email address');
}
static::$auth = [ $redirectUri = $this->redirectUri();
'access_token' => $sAccessToken,
'refresh_token' => $aResponse['refresh_token'],
'expires_in' => $aResponse['expires_in'],
'expires' => $iExpires
];
$oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']); $tenant = $this->Config()->Get('plugin', 'tenant', 'common');
$oAccount = $oActions->LoginProcess($aUserInfo['email'], $oPassword);
// $oAccount = MainAccount::NewInstanceFromCredentials($oActions, $aUserInfo['email'], $aUserInfo['email'], $oPassword, true);
if ($oAccount) {
// $oActions->SetMainAuthAccount($oAccount);
// $oActions->SetAuthToken($oAccount);
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey())
);
}
}
catch (\Exception $oException)
{
$oActions->Logger()->WriteException($oException, \LOG_ERR);
}
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
public function configMapping() : array $state = (string) $_GET['state'];
{ $statePayload = $this->verifyAndConsumeState($state);
return [ if (!$statePayload) {
\RainLoop\Plugins\Property::NewInstance('personal') $oActions->Location(\RainLoop\Utils::WebPath());
->SetLabel('Use with personal accounts') exit;
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) }
->SetDefaultValue(true)
->SetAllowedInJs()
->SetDescription('Sign in users with personal Microsoft accounts such as Outlook.com (Hotmail)'),
\RainLoop\Plugins\Property::NewInstance('client_id')
->SetLabel('Client ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetAllowedInJs()
->SetDescription('https://github.com/the-djmaze/snappymail/wiki/FAQ#o365'),
\RainLoop\Plugins\Property::NewInstance('client_secret')
->SetLabel('Client Secret')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('tenant_id')
->SetLabel('Tenant ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING),
\RainLoop\Plugins\Property::NewInstance('tenant')->SetLabel('Tenant')
->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION)
->SetDefaultValue(['common','consumers','organizations'])
->SetAllowedInJs()
];
}
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void $aTokenWrap = $oO365->getAccessToken(
{ \str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
if ($oAccount instanceof MainAccount && \str_ends_with($oAccount->Email(), '@hotmail.com')) { 'authorization_code',
$oActions = \RainLoop\Api::Actions(); [
try { 'code' => $_GET['code'],
$aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON( 'redirect_uri' => $redirectUri
$oActions->StorageProvider()->Get($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken()), ]
$oAccount->CryptKey() );
);
} catch (\Throwable $oException) {
// $oActions->Logger()->WriteException($oException, \LOG_ERR);
return;
}
if (!empty($aData['expires']) && !empty($aData['access_token']) && !empty($aData['refresh_token'])) {
if (\time() >= $aData['expires']) {
$iExpires = \time();
$oO365 = $this->o365Connector();
if ($oO365) {
$aRefreshTokenResponse = $oO365->getAccessToken(
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI),
'refresh_token',
array('refresh_token' => $aData['refresh_token'])
);
if (!empty($aRefreshTokenResponse['result']['access_token'])) {
$aData['access_token'] = $aRefreshTokenResponse['result']['access_token'];
$aResponse['expires'] = $iExpires + $aResponse['expires_in'];
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey())
);
}
}
}
$oSettings->passphrase = $aData['access_token'];
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2');
}
}
}
protected function o365Connector() : ?\OAuth2\Client if (!\is_array($aTokenWrap) || !isset($aTokenWrap['code'])) {
{ throw new \RuntimeException('Token request failed: ' . \json_encode($aTokenWrap));
$client_id = \trim($this->Config()->Get('plugin', 'client_id', '')); }
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', '')); if (200 !== (int)$aTokenWrap['code']) {
if ($client_id && $client_secret) { $err = $aTokenWrap['result']['error'] ?? '';
try $desc = $aTokenWrap['result']['error_description'] ?? '';
{ throw new \RuntimeException("Token HTTP {$aTokenWrap['code']}: {$err} / {$desc}");
$oO365 = new \OAuth2\Client($client_id, $client_secret); }
$oActions = \RainLoop\Api::Actions();
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', ''); $aToken = $aTokenWrap['result'] ?? [];
if (\strlen($sProxy)) { $accessToken = $aToken['access_token'] ?? '';
$oO365->setCurlOption(CURLOPT_PROXY, $sProxy); $refreshToken = $aToken['refresh_token'] ?? '';
$sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', ''); $expiresIn = (int)($aToken['expires_in'] ?? 0);
if (\strlen($sProxyAuth)) { $idToken = $aToken['id_token'] ?? '';
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
} if ($accessToken === '') {
} throw new \RuntimeException('access_token missing');
return $oO365; }
}
catch (\Exception $oException) if ($refreshToken === '') {
{ throw new \RuntimeException('refresh_token missing');
$oActions->Logger()->WriteException($oException, \LOG_ERR); }
} if ($idToken === '') {
} // We rely on id_token to get email/sub without Graph.
return null; throw new \RuntimeException('id_token missing (add openid email profile scopes)');
} }
// Parse id_token (JWT) to get identity (sub + email)
$claims = $this->decodeJwtPayload($idToken);
if (!\is_array($claims)) {
throw new \RuntimeException('Cannot decode id_token payload');
}
$email = $claims['email'] ?? ($claims['preferred_username'] ?? ($claims['upn'] ?? ''));
$sub = $claims['sub'] ?? '';
if ($sub === '') {
throw new \RuntimeException('unknown id from id_token');
}
if ($email === '') {
throw new \RuntimeException('unknown email address from id_token');
}
if (!$this->isSupportedEmail(\strtolower($email))) {
throw new \RuntimeException('Unsupported email domain for this plugin');
}
$tokenBundle = [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => $expiresIn,
'expires' => $iNow + $expiresIn
];
$op = $statePayload['op'] ?? 'login';
if ('add' === $op) {
$oMainAccount = $oActions->getMainAccountFromToken(false);
if (!$oMainAccount) {
throw new \RuntimeException('Add-account flow requires logged in main account');
}
if (!empty($statePayload['main']) && $statePayload['main'] !== $oMainAccount->Email()) {
throw new \RuntimeException('Add-account state does not match current main account');
}
// Store token bundle encrypted with MAIN account crypt key (never store refresh_token unencrypted).
// This is later used by imap/smtp/sieve.before-login for *additional* accounts.
$this->storeAccountTokens($oMainAccount, $email, $tokenBundle);
// Create/validate an AdditionalAccount entry exactly like SnappyMail expects in "additionalaccounts".
// We set the "password" to the OAuth subject (sub) as an opaque secret; the plugin will inject XOAUTH2.
$oPassword = new \SnappyMail\SensitiveString($sub);
$oAdditional = $oActions->LoginProcess($email, $oPassword, false);
if (!$oAdditional instanceof \RainLoop\Model\AdditionalAccount) {
throw new \RuntimeException('Failed to create additional account');
}
$asciiEmail = \SnappyMail\IDN::emailToAscii($oAdditional->Email());
if ($asciiEmail === $oMainAccount->Email()) {
throw new \RuntimeException('Cannot add main account as additional');
}
$aAccounts = $oActions->GetAccounts($oMainAccount);
$aEntry = $oAdditional->asTokenArray($oMainAccount);
if (!empty($statePayload['name']) && \is_string($statePayload['name'])) {
$aEntry['name'] = \trim($statePayload['name']);
} else if (isset($aAccounts[$asciiEmail]['name'])) {
// Preserve previous custom label if re-adding/updating.
$aEntry['name'] = (string) $aAccounts[$asciiEmail]['name'];
}
$aAccounts[$asciiEmail] = $aEntry;
$oActions->SetAccounts($oMainAccount, $aAccounts);
// Cache for this request (used during LoginProcess() above and any subsequent logins).
static::$auth[\strtolower($asciiEmail)] = $tokenBundle;
$returnHash = '';
if (!empty($statePayload['return']) && \is_string($statePayload['return']) && \str_starts_with($statePayload['return'], '#')) {
$returnHash = $statePayload['return'];
}
$oActions->Location(\RainLoop\Utils::WebPath() . $returnHash);
exit;
}
// Default: "login" flow (preserve existing behavior)
static::$auth[\strtolower($email)] = $tokenBundle;
// SnappyMail uses password as opaque string; plugin injects XOAUTH2 later.
$oPassword = new \SnappyMail\SensitiveString($sub);
$oAccount = $oActions->LoginProcess($email, $oPassword);
if ($oAccount) {
$oActions->StorageProvider()->Put(
$oAccount,
StorageType::SESSION,
\RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oAccount->CryptKey())
);
}
}
catch (\Throwable $e) {
$oActions->Logger()->WriteException($e, \LOG_ERR);
}
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
public function configMapping() : array
{
return [
\RainLoop\Plugins\Property::NewInstance('client_id')
->SetLabel('Client ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('client_secret')
->SetLabel('Client Secret')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('tenant')
->SetLabel('Tenant')
->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION)
->SetDefaultValue(['common','consumers','organizations'])
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('personal')
// When true: redirect URI uses query parameter form "/?LoginO365" (Azure supports it).
// When false: redirect URI uses path form "/LoginO365" (useful behind reverse proxies).
->SetLabel('Use "/?LoginO365" redirect URI')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('allow_any_domain')
->SetLabel('Allow any domain (not only outlook.com/hotmail.com/live.com)')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
->SetAllowedInJs()
];
}
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void
{
$email = \strtolower($oAccount->Email());
if (!$this->isSupportedEmail($email)) {
return;
}
$oActions = \RainLoop\Api::Actions();
$aData = static::$auth[$email] ?? null;
if (!$aData) {
try {
if ($oAccount instanceof MainAccount) {
$blob = $oActions->StorageProvider()->Get(
$oAccount,
StorageType::SESSION,
\RainLoop\Utils::GetSessionToken()
);
$aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey());
} else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) {
$oMain = $oActions->getMainAccountFromToken(false);
if (!$oMain) {
return;
}
$blob = $oActions->StorageProvider()->Get(
$oMain,
StorageType::CONFIG,
$this->tokenStorageKey($email)
);
$aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oMain->CryptKey());
}
} catch (\Throwable $e) {
return;
}
}
if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) {
return;
}
// Refresh if expired (or close to expiry)
if (\time() >= ((int)$aData['expires'] - 30)) {
$oO365 = $this->o365Connector();
if ($oO365) {
$tenant = $this->Config()->Get('plugin', 'tenant', 'common');
$aRefreshWrap = $oO365->getAccessToken(
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
'refresh_token',
['refresh_token' => $aData['refresh_token']]
);
if (\is_array($aRefreshWrap) && isset($aRefreshWrap['code']) && 200 === (int)$aRefreshWrap['code']) {
$r = $aRefreshWrap['result'] ?? [];
if (!empty($r['access_token'])) {
$aData['access_token'] = $r['access_token'];
}
if (!empty($r['refresh_token'])) {
$aData['refresh_token'] = $r['refresh_token'];
}
$expiresIn = (int)($r['expires_in'] ?? 0);
if ($expiresIn > 0) {
$aData['expires'] = \time() + $expiresIn;
$aData['expires_in'] = $expiresIn;
}
// Persist updated bundle (encrypted).
if ($oAccount instanceof MainAccount) {
$oActions->StorageProvider()->Put(
$oAccount,
StorageType::SESSION,
\RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey())
);
} else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) {
$oMain = $oActions->getMainAccountFromToken(false);
if ($oMain) {
$oActions->StorageProvider()->Put(
$oMain,
StorageType::CONFIG,
$this->tokenStorageKey($email),
\SnappyMail\Crypt::EncryptToJSON($aData, $oMain->CryptKey())
);
}
}
}
}
}
static::$auth[$email] = $aData;
// Inject XOAUTH2/OAUTHBEARER
$oSettings->passphrase = $aData['access_token'];
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2');
}
/**
* Server-side cleanup hook: after a successful AccountDelete, remove stored token bundle for that email.
* This prevents leaving encrypted refresh tokens behind when an additional account is removed.
*/
public function afterAccountDelete(array &$aResponse) : void
{
if (empty($aResponse['Result'])) {
return;
}
$oActions = \RainLoop\Api::Actions();
$oMain = $oActions->getMainAccountFromToken(false);
if (!$oMain) {
return;
}
$email = \strtolower(\SnappyMail\IDN::emailToAscii(\trim((string) $oActions->GetActionParam('emailToDelete', ''))));
if ($email && $this->isSupportedEmail($email)) {
$oActions->StorageProvider()->Clear($oMain, StorageType::CONFIG, $this->tokenStorageKey($email));
}
}
protected function o365Connector() : ?\OAuth2\Client
{
$client_id = \trim($this->Config()->Get('plugin', 'client_id', ''));
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if ($client_id && $client_secret) {
try {
$oO365 = new \OAuth2\Client($client_id, $client_secret);
$oActions = \RainLoop\Api::Actions();
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', '');
if (\strlen($sProxy)) {
$oO365->setCurlOption(CURLOPT_PROXY, $sProxy);
$sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', '');
if (\strlen($sProxyAuth)) {
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
}
}
return $oO365;
} catch (\Throwable $e) {
\RainLoop\Api::Actions()->Logger()->WriteException($e, \LOG_ERR);
}
}
return null;
}
private function decodeJwtPayload(string $jwt) : ?array
{
$parts = \explode('.', $jwt);
if (\count($parts) < 2) {
return null;
}
$payload = $parts[1];
$payload .= \str_repeat('=', (4 - (\strlen($payload) % 4)) % 4);
$json = \base64_decode(\strtr($payload, '-_', '+/'));
if ($json === false) {
return null;
}
$data = \json_decode($json, true);
return \is_array($data) ? $data : null;
}
/**
* JSON action called by JS to obtain an MS authorize URL with signed state.
* This avoids exposing any signing secret to JS and keeps redirect_uri consistent with server logic.
*/
public function DoLoginO365AuthUrl() : array
{
$oActions = \RainLoop\Api::Actions();
$op = (string) $this->jsonParam('op', 'login');
if (!\in_array($op, ['login', 'add'], true)) {
return $this->jsonResponse(__FUNCTION__, false);
}
$email = \strtolower(\trim((string) $this->jsonParam('email', '')));
$name = \trim((string) $this->jsonParam('name', ''));
$returnHash = (string) $this->jsonParam('return', '');
if ($returnHash && !\str_starts_with($returnHash, '#')) {
$returnHash = '';
}
// For add-account flow, require a logged-in main account (we must write to its additionalaccounts storage).
$oMainAccount = null;
if ('add' === $op) {
$oMainAccount = $oActions->getMainAccountFromToken(false);
if (!$oMainAccount) {
return $this->jsonResponse(__FUNCTION__, false);
}
}
// Optional server-side guard: only permit supported consumer domains unless configured otherwise.
if ($email && !$this->isSupportedEmail($email)) {
return $this->jsonResponse(__FUNCTION__, false);
}
$oConfig = $this->Config();
$client_id = \trim($oConfig->Get('plugin', 'client_id', ''));
if (!$client_id) {
return $this->jsonResponse(__FUNCTION__, false);
}
$nonce = $this->b64url(\random_bytes(16));
// Store nonce server-side to prevent replay; consumed on callback.
$oActions->StorageProvider()->Put(
null,
StorageType::NOBODY,
$this->stateNonceKey($nonce),
(string) \time()
);
$payload = [
'v' => 1,
'op' => $op,
'csrf' => \RainLoop\Utils::GetCsrfToken(),
'nonce' => $nonce,
'ts' => \time()
];
if ('add' === $op && $oMainAccount) {
$payload['main'] = $oMainAccount->Email();
if ($name) {
$payload['name'] = \substr($name, 0, 100);
}
if ($returnHash) {
$payload['return'] = \substr($returnHash, 0, 200);
}
}
$state = $this->signState($payload);
$tenant = $oConfig->Get('plugin', 'tenant', 'common');
$redirectUri = $this->redirectUri();
$params = [
'response_type' => 'code',
'client_id' => $client_id,
'redirect_uri' => $redirectUri,
'scope' => \implode(' ', [
'openid',
'offline_access',
'email',
'profile',
'https://outlook.office.com/IMAP.AccessAsUser.All',
'https://outlook.office.com/SMTP.Send',
]),
'state' => $state,
// Helps MS UI prefill, but does not change server-side validation.
];
if ($email) {
$params['login_hint'] = $email;
}
$authUrl = \str_replace('{{tenant}}', $tenant, static::AUTH_URI)
. '?'
. \http_build_query($params, '', '&', PHP_QUERY_RFC3986);
return $this->jsonResponse(__FUNCTION__, [
'authUrl' => $authUrl
]);
}
private function isSupportedEmail(string $email) : bool
{
if ((bool)$this->Config()->Get('plugin', 'allow_any_domain', false)) {
return \str_contains($email, '@');
}
return \str_ends_with($email, '@hotmail.com')
|| \str_ends_with($email, '@outlook.com')
|| \str_ends_with($email, '@live.com');
}
/**
* Build absolute base URL (works behind nginx reverse proxy).
*/
private function baseUrl() : string
{
$scheme = (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']))
? $_SERVER['HTTP_X_FORWARDED_PROTO']
: ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
if (!$host) {
throw new \RuntimeException('Cannot determine HTTP_HOST');
}
return $scheme . '://' . $host;
}
/**
* Redirect URI used for the Azure app registration.
* When plugin.personal=true -> "/?LoginO365"
* When plugin.personal=false -> "/LoginO365"
*/
private function redirectUri() : string
{
$base = \rtrim($this->baseUrl(), '/');
$useQuery = (bool)$this->Config()->Get('plugin', 'personal', false);
return $useQuery ? ($base . '/?LoginO365') : ($base . '/LoginO365');
}
private function tokenStorageKey(string $emailLower) : string
{
// Stored under MAIN account StorageType::CONFIG (encrypted with main CryptKey).
// Email is hashed to avoid path/encoding issues across storage backends.
return 'login-o365.tokens.' . \sha1($emailLower);
}
private function storeAccountTokens(MainAccount $oMainAccount, string $email, array $tokenBundle) : void
{
$emailLower = \strtolower(\SnappyMail\IDN::emailToAscii($email));
\RainLoop\Api::Actions()->StorageProvider()->Put(
$oMainAccount,
StorageType::CONFIG,
$this->tokenStorageKey($emailLower),
\SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oMainAccount->CryptKey())
);
}
private function stateNonceKey(string $nonce) : string
{
return 'login-o365.state.' . $nonce;
}
private function b64url(string $bin) : string
{
return \rtrim(\strtr(\base64_encode($bin), '+/', '-_'), '=');
}
private function b64urlDecode(string $b64url) /*: string|false*/
{
$pad = (4 - (\strlen($b64url) % 4)) % 4;
return \base64_decode(\strtr($b64url . \str_repeat('=', $pad), '-_', '+/'), true);
}
private function stateHmacKey() : string
{
// Uses the plugin client_secret (server-side only) as HMAC key.
// This prevents any user-controlled tampering of the state payload.
$key = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if (!$key) {
// Fallback for misconfiguration; keeps behavior deterministic.
$key = 'login-o365';
}
return $key;
}
private function signState(array $payload) : string
{
$json = \json_encode($payload);
if (!$json) {
$json = '{}';
}
$payloadB64 = $this->b64url($json);
$sig = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
return $payloadB64 . '.' . $this->b64url($sig);
}
/**
* Verify signature + CSRF + nonce, then consumes nonce to prevent replay.
* Returns decoded payload on success, null on failure.
*/
private function verifyAndConsumeState(string $state) : ?array
{
$parts = \explode('.', $state, 2);
if (2 !== \count($parts)) {
return null;
}
[$payloadB64, $sigB64] = $parts;
$sig = $this->b64urlDecode($sigB64);
if ($sig === false) {
return null;
}
$expected = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
if (!\hash_equals($expected, $sig)) {
return null;
}
$payloadJson = $this->b64urlDecode($payloadB64);
if ($payloadJson === false) {
return null;
}
$payload = \json_decode($payloadJson, true);
if (!\is_array($payload) || empty($payload['csrf']) || empty($payload['nonce']) || empty($payload['op'])) {
return null;
}
// Must match the current browser session.
if ($payload['csrf'] !== \RainLoop\Utils::GetCsrfToken()) {
return null;
}
// Replay protection: nonce must exist server-side and is consumed once.
$oActions = \RainLoop\Api::Actions();
$key = $this->stateNonceKey((string) $payload['nonce']);
$seen = $oActions->StorageProvider()->Get(null, StorageType::NOBODY, $key);
if (!$seen) {
return null;
}
$ts = (int) ($payload['ts'] ?? 0);
if ($ts && \abs(\time() - $ts) > 900) { // 15 minutes
// Expired: clear nonce to avoid accumulating stale entries.
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
return null;
}
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
return $payload;
}
} }

View file

@ -6,8 +6,8 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Login Remote', NAME = 'Login Remote',
AUTHOR = 'SnappyMail', AUTHOR = 'SnappyMail',
URL = 'https://snappymail.eu/', URL = 'https://snappymail.eu/',
VERSION = '1.5', VERSION = '1.6',
RELEASE = '2024-09-20', RELEASE = '2026-10-22',
REQUIRED = '2.36.1', REQUIRED = '2.36.1',
CATEGORY = 'Login', CATEGORY = 'Login',
LICENSE = 'MIT', LICENSE = 'MIT',
@ -43,6 +43,7 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
try try
{ {
static::$login = true; static::$login = true;
$sPassword = new \SnappyMail\SensitiveString($sPassword);
$oAccount = $oActions->LoginProcess($sEmail, $sPassword); $oAccount = $oActions->LoginProcess($sEmail, $sPassword);
} }
catch (\Throwable $oException) catch (\Throwable $oException)

View file

@ -19,36 +19,37 @@ The following steps are require in SnappyMail:
- Master User is dependent on Dovecot config (see below) - Master User is dependent on Dovecot config (see below)
- Master User Password is dependent on Dovecot config (see below) - Master User Password is dependent on Dovecot config (see below)
- Header Name is dependent on authentication solution. This is the header containing the name of currently logged in user. In case of Authelia, this is "Remote-User". - Header Name is dependent on authentication solution. This is the header containing the name of currently logged in user. In case of Authelia, this is "Remote-User".
- Check Proxy: Since this plugin partially bypasses authentication, it is important to only allow this access from well-defined hosts. It is highly recommended to activate this option!
- When checking for reverse proxy, it is required to set the IP filter to either an IP address or a subnet.
- Automatic Login: Automatically logs in the user of user header is present (see below) - Automatic Login: Automatically logs in the user of user header is present (see below)
> **Security note**
>
> This plugin trusts the configured request header as proof of identity. Anyone who can reach the SnappyMail container directly and set that header can log in as any user. You **must** ensure that SnappyMail is only reachable through your reverse proxy / SSO chain (e.g. via the docker network, a firewall, or by binding SnappyMail to a non-public interface) and that the upstream proxy strips any client-supplied value of the header before forwarding. The plugin itself does no source-IP validation — earlier versions had a `check_proxy` option, but it inspected the forwarded client IP (the end user's IP) and therefore did not actually verify that the request came from the proxy. It has been removed; gate access at the network layer instead.
This concludes the setup of SnappyMail. This concludes the setup of SnappyMail.
### Dovecot ### Dovecot
In Dovecot, you need to enable Master User. In Dovecot, you need to enable Master User.
Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf. Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf.
The file /etc/dovecot/conf.d/auth-master.conf.ext should contain: In Dovecot 2.3, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
``` ```
# Authentication for master users. Included from auth.conf.
# By adding master=yes setting inside a passdb you make the passdb a list
# of "master users", who can log in as anyone else.
# <doc/wiki/Authentication.MasterUsers.txt>
# Example master user passdb using passwd-file. You can use any passdb though.
passdb { passdb {
driver = passwd-file driver = passwd-file
master = yes master = yes
args = /etc/dovecot/master-users args = /etc/dovecot/master-users
# Unless you're using PAM, you probably still want the destination user to
# be looked up from passdb that it really exists. pass=yes does that.
pass = yes pass = yes
} }
``` ```
In Dovecot 2.4, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
```
passdb passwd-file {
master = yes
passwd_file_path = /etc/dovecot/master-users
result_success = continue
}
```
You then need to create a master user in /etc/dovecot/master-users: You then need to create a master user in /etc/dovecot/master-users:
``` ```
admin:PASSWORD::::::allow_nets=local,172.17.0.0/16 admin:PASSWORD::::::allow_nets=local,172.17.0.0/16
@ -80,3 +81,11 @@ The user is always considered logged in, as authentication is handled through re
Auto login can be disabled in the plugin settings. Auto login can be disabled in the plugin settings.
You can also change the logout link in admin panel -> Config -> custom_logout_link to the one of your authentication system, e.g., ```https://auth.yourdomain.com/logout```. You can also change the logout link in admin panel -> Config -> custom_logout_link to the one of your authentication system, e.g., ```https://auth.yourdomain.com/logout```.
In this case, you can log out from your overall system via SnappyMail. In this case, you can log out from your overall system via SnappyMail.
## Troubleshooting
### IMAP `AUTHENTICATIONFAILED` after a container rebuild / upgrade
The master user/password fields are encrypted at rest using SnappyMail's `APP_SALT`. If that salt is regenerated (e.g., the data volume was reset, the container was rebuilt without persisting `_data_`, or the salt file was rotated), the values in `plugin-proxy-auth.json` can no longer be decrypted. `getDecrypted()` then silently returns `null`, an empty password is passed to IMAP, and Dovecot rejects the login with `AUTHENTICATIONFAILED`.
Fix: open admin panel -> Extensions -> Proxy Auth, re-enter the Master User and Master Password (and any other previously-set encrypted fields), and save. The values will be re-encrypted under the current salt.

View file

@ -6,8 +6,8 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Proxy Auth', NAME = 'Proxy Auth',
AUTHOR = 'Philipp', AUTHOR = 'Philipp',
URL = 'https://www.mundhenk.org/', URL = 'https://www.mundhenk.org/',
VERSION = '0.5', VERSION = '0.7',
RELEASE = '2024-09-20', RELEASE = '2026-05-17',
REQUIRED = '2.36.1', REQUIRED = '2.36.1',
CATEGORY = 'Login', CATEGORY = 'Login',
LICENSE = 'MIT', LICENSE = 'MIT',
@ -21,26 +21,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
$this->addHook('login.credentials', 'MapEmailAddress'); $this->addHook('login.credentials', 'MapEmailAddress');
} }
/* by https://gist.github.com/tott/7684443 */
/**
* Check if a given ip is in a network
* @param string $ip IP to check in IPV4 format eg. 127.0.0.1
* @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed
* @return boolean true if the ip is in this range / false if not.
*/
private function ip_in_range( $ip, $range ) {
if ( strpos( $range, '/' ) == false ) {
$range .= '/32';
}
// $range is in IP/CIDR format eg 127.0.0.1/24
list( $range, $netmask ) = explode( '/', $range, 2 );
$range_decimal = ip2long( $range );
$ip_decimal = ip2long( $ip );
$wildcard_decimal = pow( 2, ( 32 - $netmask ) ) - 1;
$netmask_decimal = ~ $wildcard_decimal;
return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) );
}
public function MapEmailAddress(string &$sEmail, string &$sImapUser, string &$sPassword, string &$sSmtpUser) public function MapEmailAddress(string &$sEmail, string &$sImapUser, string &$sPassword, string &$sSmtpUser)
{ {
$oActions = \RainLoop\Api::Actions(); $oActions = \RainLoop\Api::Actions();
@ -80,59 +60,19 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
$sMsg = "Remote User: " . $sRemoteUser; $sMsg = "Remote User: " . $sRemoteUser;
$oLogger->Write($sMsg, $sLevel, $sPrefix); $oLogger->Write($sMsg, $sLevel, $sPrefix);
$sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', ''); /* create master user login from remote user header and settings */
$sMsg = "ProxyIP: " . $sProxyIP; $sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser;
$oLogger->Write($sMsg, $sLevel, $sPrefix); $sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', '')));
$sProxyCheck = $this->Config()->getDecrypted('plugin', 'proxy_check', ''); try
$sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true); {
static::$login = true;
/* make sure that remote user is only set by authorized proxy to avoid security risks */ $oAccount = $oActions->LoginProcess($sEmail, $sPassword);
if ($sProxyCheck) {
$sProxyRequest = false;
$sMsg = "checking client IPs: " . $sClientIPs;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
$sClientIPs = explode(", ", $sClientIPs);
if (is_array($sClientIPs)) {
foreach ($sClientIPs as &$sIP) {
$sMsg = "checking client IP: " . $sIP;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
if ($this->ip_in_range($sIP, $sProxyIP)) {
$sProxyRequest = true;
}
}
} else {
$sMsg = "checking client IP: " . $sClientIPs;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
if ($this->ip_in_range($sClientIPs, $sProxyIP)) {
$sProxyRequest = true;
}
}
} else {
$sProxyRequest = true;
} }
catch (\Throwable $oException)
if ($sProxyRequest) { {
/* create master user login from remote user header and settings */ $oLogger = $oActions->Logger();
$sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser; $oLogger && $oLogger->WriteException($oException);
$sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', '')));
try
{
static::$login = true;
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
}
catch (\Throwable $oException)
{
$oLogger = $oActions->Logger();
$oLogger && $oLogger->WriteException($oException);
}
\MailSo\Base\Http::Location('./');
return true;
} }
\MailSo\Base\Http::Location('./'); \MailSo\Base\Http::Location('./');
@ -188,18 +128,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetDescription('Name of header containing username') ->SetDescription('Name of header containing username')
->SetDefaultValue('Remote-User') ->SetDefaultValue('Remote-User')
->SetEncrypted(), ->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('check_proxy')
->SetLabel('Check Proxy')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDescription('Activates check if proxy is connecting')
->SetDefaultValue(true)
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('proxy_ip')
->SetLabel('Proxy IPNet')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT)
->SetDescription('IP or Subnet of proxy, auth header will only be accepted from this address')
->SetDefaultValue('10.1.0.0/24')
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('auto_login') \RainLoop\Plugins\Property::NewInstance('auto_login')
->SetAllowedInJs(true) ->SetAllowedInJs(true)
->SetLabel('Activate automatic login') ->SetLabel('Activate automatic login')

View file

@ -6,8 +6,8 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Search Filters', NAME = 'Search Filters',
AUTHOR = 'AbdoBnHesham', AUTHOR = 'AbdoBnHesham',
URL = 'https://github.com/the-djmaze/snappymail/pull/1673', URL = 'https://github.com/the-djmaze/snappymail/pull/1673',
VERSION = '0.2', VERSION = '0.3',
RELEASE = '2024-06-28', RELEASE = '2026-10-22',
REQUIRED = '2.36.3', REQUIRED = '2.36.3',
CATEGORY = 'General', CATEGORY = 'General',
LICENSE = 'MIT', LICENSE = 'MIT',
@ -51,45 +51,33 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
$Filters = $aSettings['SFilters']; $Filters = $aSettings['SFilters'];
foreach ($Filters as $filter) { foreach ($Filters as $filter) {
$this->Manager()->logWrite(json_encode([ $this->Manager()->logWrite(json_encode(['filter' => $filter]), LOG_WARNING);
'filter' => $filter,
]), LOG_WARNING);
$folder = 'INBOX';
$searchQ = $filter['searchQ']; $searchQ = $filter['searchQ'];
$uids = $this->searchMessages($oImapClient, $searchQ, "INBOX");
//Mark as read/seen // obsługa keyword (multi-folder)
if ($filter['fSeen']) { if (stripos($searchQ, 'keyword=') !== false) {
foreach ($uids as $uid) { $oMailClient = $this->Manager()->Actions()->MailClient();
$oRange = new MailSo\Imap\SequenceSet([$uid]);
$this->Manager()->Actions()->MailClient()->MessageSetFlag( // Pobieramy wszystkie foldery top-level
$folder, $folders = $oMailClient->Folders('', false, '');
$oRange, foreach ($folders as $f) {
MailSo\Imap\Enumerations\MessageFlag::SEEN $folder = $f->FullName ?? null;
); if (!$folder) continue;
$uids = $this->searchMessages($oImapClient, $searchQ, $folder);
if (!empty($uids)) {
$this->applyActions($oImapClient, $filter, $uids, $folder);
}
} }
continue; // przechodzimy do następnego filtra
} }
//Flag/Star message // normalny search w INBOX
if ($filter['fFlag']) { $uids = $this->searchMessages($oImapClient, $searchQ, 'INBOX');
foreach ($uids as $uid) { if (!empty($uids)) {
$oRange = new MailSo\Imap\SequenceSet([$uid]); $this->applyActions($oImapClient, $filter, $uids, 'INBOX');
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
$folder,
$oRange,
MailSo\Imap\Enumerations\MessageFlag::FLAGGED
);
}
}
// Move to folder
if ($filter['fFolder']) {
$folder = $filter['fFolder'];
foreach ($uids as $uid) {
$oRange = new MailSo\Imap\SequenceSet([$uid]);
$oImapClient->MessageMove("INBOX", $folder, $oRange);
}
} }
} }
} }
@ -99,21 +87,66 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
string $search, string $search,
string $folder = "INBOX" string $folder = "INBOX"
): array { ): array {
$oParams = new \MailSo\Mail\MessageListParams(); try {
$oParams->sSearch = $search; $bUseCache = false;
$oParams->sFolderName = $folder; $oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString(
$imapClient,
$folder,
$search,
true,
$bUseCache
);
$bUseCache = false; $imapClient->FolderSelect($folder);
$oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString( return $imapClient->MessageSearch($oSearchCriterias, true);
$imapClient,
$folder,
$search,
true,
$bUseCache
);
$imapClient->FolderSelect($folder); } catch (\Throwable $e) {
return $imapClient->MessageSearch($oSearchCriterias, true); $this->Manager()->logWrite(
'SearchFilters IMAP error in folder "' . $folder .
'" for search "' . $search . '": ' . $e->getMessage(),
LOG_ERR
);
return [];
}
}
private function applyActions(
\MailSo\Imap\ImapClient $imapClient,
array $filter,
array $uids,
string $folder
) {
// Mark as read/seen
if (!empty($filter['fSeen'])) {
foreach ($uids as $uid) {
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
$folder,
$oRange,
\MailSo\Imap\Enumerations\MessageFlag::SEEN
);
}
}
// Flag/Star message
if (!empty($filter['fFlag'])) {
foreach ($uids as $uid) {
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
$folder,
$oRange,
\MailSo\Imap\Enumerations\MessageFlag::FLAGGED
);
}
}
// Move to folder
if (!empty($filter['fFolder']) && $filter['fFolder'] !== -1) {
foreach ($uids as $uid) {
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
$imapClient->MessageMove('INBOX', $filter['fFolder'], $oRange);
}
}
} }
public function GetFilters() public function GetFilters()
@ -173,7 +206,6 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
} else { } else {
$aSettings['SFilters'][$foundIndex] = $newFilter; $aSettings['SFilters'][$foundIndex] = $newFilter;
} }
return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings)); return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings));
} }

View file

@ -131,7 +131,7 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
private static function getQRCode(MainAccount $oAccount, string $secret) : string private static function getQRCode(MainAccount $oAccount, string $secret) : string
{ {
$email = \rawurlencode($oAccount->Email()); $email = \rawurlencode($oAccount->Email());
// $issuer = \rawurlencode(\RainLoop\API::Config()->Get('webmail', 'title', 'SnappyMail')); // $issuer = \rawurlencode(\RainLoop\Api::Config()->Get('webmail', 'title', 'SnappyMail'));
$QR = \SnappyMail\QRCode::getMinimumQRCode( $QR = \SnappyMail\QRCode::getMinimumQRCode(
// "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}", // "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}",
"otpauth://totp/{$email}?secret={$secret}", "otpauth://totp/{$email}?secret={$secret}",

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
BUTTON_HIDE_SECRET = "Hide Secret" BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured" TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n" TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive.\n" TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test." TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test" TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code" LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
BUTTON_HIDE_SECRET = "Hide Secret" BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured" TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n" TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive.\n" TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test." TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test" TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code" LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Titok megjelenítése"
BUTTON_HIDE_SECRET = "Titok elrejtése" BUTTON_HIDE_SECRET = "Titok elrejtése"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Nincs beállítva" TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Nincs beállítva"
TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával.\n" TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával."
TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik.\n" TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Tesztelés nélkül nem lehet megváltoztatni ezt a beállítást." TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Tesztelés nélkül nem lehet megváltoztatni ezt a beállítást."
TITLE_TEST_CODE = "2-lépéses hitelesítés teszt" TITLE_TEST_CODE = "2-lépéses hitelesítés teszt"
LABEL_CODE = "Kód" LABEL_CODE = "Kód"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Mostra la chiave segreta"
BUTTON_HIDE_SECRET = "Nascondi la chiave segreta" BUTTON_HIDE_SECRET = "Nascondi la chiave segreta"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Non configurato" TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Non configurato"
TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente.\n" TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente."
TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo.\n" TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test." TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test."
TITLE_TEST_CODE = "Test di verifica a 2 fattori" TITLE_TEST_CODE = "Test di verifica a 2 fattori"
LABEL_CODE = "Codice" LABEL_CODE = "Codice"

View file

@ -0,0 +1,20 @@
[PLUGIN_2FA]
LEGEND_TWO_FACTOR_AUTH = "2-етапна перевірка (TOTP)"
LABEL_ENABLE_TWO_FACTOR = "Увімкнути 2-етапну перевірку"
LABEL_TWO_FACTOR_USER = "Ім'я користувача"
LABEL_TWO_FACTOR_STATUS = "Статус"
LABEL_TWO_FACTOR_SECRET = "Ключ"
LABEL_TWO_FACTOR_BACKUP_CODES = "Резервні коди"
BUTTON_CREATE = "Create a secret"
BUTTON_ACTIVATE = "Activate"
LINK_TEST = "Тестувати"
BUTTON_SHOW_SECRET = "Показати ключ"
BUTTON_HIDE_SECRET = "Приховати ключ"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Налаштовано"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Не налаштовано"
TWO_FACTOR_SECRET_DESC = "Імпортуйте цю інформацію у свій клієнт Google Authenticator (або інший клієнт TOTP), використовуючи наданий нижче QR-код або ввівши код вручну"
TWO_FACTOR_BACKUP_CODES_DESC = "Якщо ви не можете отримати код через Google Authenticator (або інший клієнт TOTP), ви можете скористатися резервними кодами для входу. Після того, як ви використаєте резервний код для входу, він стане неактивним. Закреслюйте його"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Ви не можете змінити це налаштування перед тестуванням."
TITLE_TEST_CODE = "Тест 2-етапної перевірки"
LABEL_CODE = "Код"
LABEL_TWO_FACTOR_CODE = "Код підтвердження"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "显示密钥"
BUTTON_HIDE_SECRET = "隐藏密钥" BUTTON_HIDE_SECRET = "隐藏密钥"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置" TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置" TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置"
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。\n" TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。"
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。\n" TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。" TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。"
TITLE_TEST_CODE = "两步验证测试" TITLE_TEST_CODE = "两步验证测试"
LABEL_CODE = "代码" LABEL_CODE = "代码"

View file

@ -54,11 +54,22 @@ abstract class DateTimeHelper
\DateTime::createFromFormat('d M Y H:i:s O', $sDateTime, static::GetUtcTimeZoneObject()) \DateTime::createFromFormat('d M Y H:i:s O', $sDateTime, static::GetUtcTimeZoneObject())
// Using T (obsolete timezone abbreviation) // Using T (obsolete timezone abbreviation)
?: \DateTime::createFromFormat('d M Y H:i:s T', $sDateTime, static::GetUtcTimeZoneObject()); ?: \DateTime::createFromFormat('d M Y H:i:s T', $sDateTime, static::GetUtcTimeZoneObject());
// 398045302 is 1982-08-13 00:08:22 the date RFC 822 was created
if (!$oDateTime || 398045302 > $oDateTime->getTimestamp()) { try {
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'"); $timestamp = $oDateTime->getTimestamp();
// 398045302 is 1982-08-13 00:08:22, the date RFC 822 was created
if (398045302 > $timestamp) {
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'");
return 0;
}
return $timestamp;
} catch (\Throwable $error) {
// Catch integer overflow or other fatal errors
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'. {$error->getMessage()}");
return 0;
} }
return $oDateTime ? $oDateTime->getTimestamp() : 0;
} }
/** /**

View file

@ -160,13 +160,20 @@ abstract class HtmlUtils
// Remove all remaining data-* attributes // Remove all remaining data-* attributes
foreach ($xpath->query('//*[@*[starts-with(name(), "data-")]]') as $oElement) { foreach ($xpath->query('//*[@*[starts-with(name(), "data-")]]') as $oElement) {
$sTagNameLower = \strtolower($oElement->nodeName);
if ($oElement->hasAttributes()) { if ($oElement->hasAttributes()) {
// DOMNamedNodeMap is live: removing the attribute the foreach is
// currently on ends the iteration, so exactly one data-* was
// removed per element however many it carried. Collect the
// names first, then remove.
$aNames = [];
foreach ($oElement->attributes as $oAttr) { foreach ($oElement->attributes as $oAttr) {
if ('data-' === \substr(\strtolower($oAttr->nodeName), 0, 5)) { if ('data-' === \substr(\strtolower($oAttr->nodeName), 0, 5)) {
$oElement->removeAttribute($oAttr->nodeName); $aNames[] = $oAttr->nodeName;
} }
} }
foreach ($aNames as $sName) {
$oElement->removeAttribute($sName);
}
} }
} }

View file

@ -59,7 +59,7 @@ abstract class Utils
return 'iso-8859-8'; return 'iso-8859-8';
} }
return \preg_replace('/^(cp-?|windows?)(12[\d])/', 'windows-$1', $sEncoding); return \preg_replace('/^(cp|windows)-?(12[\d]*)/i', 'windows-$2', $sEncoding);
} }
public static function NormalizeCharsetByValue(string $sCharset, string $sValue) : string public static function NormalizeCharsetByValue(string $sCharset, string $sValue) : string

View file

@ -150,7 +150,7 @@ class Logger extends \SplFixedArray
\E_USER_ERROR => \LOG_ERR, \E_USER_ERROR => \LOG_ERR,
\E_USER_WARNING => \LOG_WARNING, \E_USER_WARNING => \LOG_WARNING,
\E_USER_NOTICE => \LOG_NOTICE, \E_USER_NOTICE => \LOG_NOTICE,
\E_STRICT => \LOG_CRIT, // \E_STRICT => \LOG_CRIT,
\E_RECOVERABLE_ERROR => \LOG_ERR, \E_RECOVERABLE_ERROR => \LOG_ERR,
\E_DEPRECATED => \LOG_INFO, \E_DEPRECATED => \LOG_INFO,
\E_USER_DEPRECATED => \LOG_INFO \E_USER_DEPRECATED => \LOG_INFO
@ -168,7 +168,7 @@ class Logger extends \SplFixedArray
\E_USER_ERROR => '-USER', \E_USER_ERROR => '-USER',
\E_USER_WARNING => '-USER', \E_USER_WARNING => '-USER',
\E_USER_NOTICE => '-USER', \E_USER_NOTICE => '-USER',
\E_STRICT => '-STRICT', // \E_STRICT => '-STRICT',
\E_RECOVERABLE_ERROR => '-RECOVERABLE', \E_RECOVERABLE_ERROR => '-RECOVERABLE',
\E_DEPRECATED => '-DEPRECATED', \E_DEPRECATED => '-DEPRECATED',
\E_USER_DEPRECATED => '-USER_DEPRECATED' \E_USER_DEPRECATED => '-USER_DEPRECATED'

View file

@ -136,14 +136,12 @@ class HeaderCollection extends \MailSo\Base\Collection
$sHeadersValue = \rtrim($sHeadersValue); $sHeadersValue = \rtrim($sHeadersValue);
} }
if ('=?' === \substr(\ltrim($sHeadersValue), 0, 2)) { // RFC 5322 unfolding removes the line break and keeps the folding
$sHeadersValue = \ltrim($sHeadersValue); // whitespace; RFC 2047 6.2 drops it only between two encoded words.
} if ('=?' === \substr(\ltrim($sHeadersValue), 0, 2) && '?=' === \substr($sValue, -2)) {
$sValue .= \ltrim($sHeadersValue);
if ('=?' === \substr($sHeadersValue, 0, 2)) {
$sValue .= $sHeadersValue;
} else { } else {
$sValue .= "\n".$sHeadersValue; $sValue .= ' '.\ltrim($sHeadersValue);
} }
} else { } else {
if (null !== $sName) { if (null !== $sName) {

View file

@ -39,7 +39,7 @@ class SSLContext implements \JsonSerializable
public function __construct() public function __construct()
{ {
$oConfig = \RainLoop\API::Config(); $oConfig = \RainLoop\Api::Config();
$this->verify_peer = !!$oConfig->Get('ssl', 'verify_certificate', true); $this->verify_peer = !!$oConfig->Get('ssl', 'verify_certificate', true);
$this->verify_peer_name = !!$oConfig->Get('ssl', 'verify_certificate', true); $this->verify_peer_name = !!$oConfig->Get('ssl', 'verify_certificate', true);
$this->allow_self_signed = !!$oConfig->Get('ssl', 'allow_self_signed', false); $this->allow_self_signed = !!$oConfig->Get('ssl', 'allow_self_signed', false);

View file

@ -33,7 +33,7 @@ class Settings extends \MailSo\Net\ConnectSettings
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
$oConfig = \RainLoop\API::Config(); $oConfig = \RainLoop\Api::Config();
$this->viewErrors = !!$oConfig->Get('labs', 'smtp_show_server_errors', false); $this->viewErrors = !!$oConfig->Get('labs', 'smtp_show_server_errors', false);
} }

View file

@ -203,7 +203,8 @@ class SmtpClient extends \MailSo\Net\NetClient
$SASL->verify($sResult); $SASL->verify($sResult);
// Now end the authentication // Now end the authentication
$sRequest = ''; $sRequest = '';
$this->sendRequestWithCheck($sRequest, 235); $this->sendRaw($sRequest, true, '');
$this->validateResponse(235, '');
} else switch ($type) { } else switch ($type) {
// RFC 4616 // RFC 4616
case 'PLAIN': case 'PLAIN':

View file

@ -505,7 +505,7 @@ class Actions
return $this->oPlugins; return $this->oPlugins;
} }
protected function LoggerAuthHelper(?Model\Account $oAccount, string $sLogin = '', bool $admin = false): void public function LoggerAuthHelper(?Model\Account $oAccount, string $sLogin = '', bool $admin = false): void
{ {
if ($sLogin) { if ($sLogin) {
$sHost = $admin ? $this->Http()->GetHost(true, true) : \MailSo\Base\Utils::getEmailAddressDomain($sLogin); $sHost = $admin ? $this->Http()->GetHost(true, true) : \MailSo\Base\Utils::getEmailAddressDomain($sLogin);
@ -591,6 +591,7 @@ class Actions
'ListInlineAttachments' => false, 'ListInlineAttachments' => false,
'CollapseBlockquotes' => $oConfig->Get('defaults', 'collapse_blockquotes', true), 'CollapseBlockquotes' => $oConfig->Get('defaults', 'collapse_blockquotes', true),
'MaxBlockquotesLevel' => 0, 'MaxBlockquotesLevel' => 0,
'minFontSize' => 0,
'simpleAttachmentsList' => false, 'simpleAttachmentsList' => false,
'listGrouped' => $oConfig->Get('defaults', 'mail_list_grouped', false), 'listGrouped' => $oConfig->Get('defaults', 'mail_list_grouped', false),
'MessagesPerPage' => \max(10, \intval($oConfig->Get('webmail', 'messages_per_page', 25)) ?: 25), 'MessagesPerPage' => \max(10, \intval($oConfig->Get('webmail', 'messages_per_page', 25)) ?: 25),
@ -687,6 +688,7 @@ class Actions
$aResult['ListInlineAttachments'] = (bool)$oSettings->GetConf('ListInlineAttachments', $aResult['ListInlineAttachments']); $aResult['ListInlineAttachments'] = (bool)$oSettings->GetConf('ListInlineAttachments', $aResult['ListInlineAttachments']);
$aResult['CollapseBlockquotes'] = (bool)$oSettings->GetConf('CollapseBlockquotes', $aResult['CollapseBlockquotes']); $aResult['CollapseBlockquotes'] = (bool)$oSettings->GetConf('CollapseBlockquotes', $aResult['CollapseBlockquotes']);
$aResult['MaxBlockquotesLevel'] = (int)$oSettings->GetConf('MaxBlockquotesLevel', $aResult['MaxBlockquotesLevel']); $aResult['MaxBlockquotesLevel'] = (int)$oSettings->GetConf('MaxBlockquotesLevel', $aResult['MaxBlockquotesLevel']);
$aResult['minFontSize'] = (int)$oSettings->GetConf('minFontSize', $aResult['minFontSize']);
$aResult['simpleAttachmentsList'] = (bool)$oSettings->GetConf('simpleAttachmentsList', $aResult['simpleAttachmentsList']); $aResult['simpleAttachmentsList'] = (bool)$oSettings->GetConf('simpleAttachmentsList', $aResult['simpleAttachmentsList']);
$aResult['listGrouped'] = (bool)$oSettings->GetConf('listGrouped', $aResult['listGrouped']); $aResult['listGrouped'] = (bool)$oSettings->GetConf('listGrouped', $aResult['listGrouped']);
$aResult['ContactsAutosave'] = (bool)$oSettings->GetConf('ContactsAutosave', $aResult['ContactsAutosave']); $aResult['ContactsAutosave'] = (bool)$oSettings->GetConf('ContactsAutosave', $aResult['ContactsAutosave']);

View file

@ -210,6 +210,7 @@ trait Accounts
'mainEmail' => \RainLoop\Api::Actions()->getMainAccountFromToken()->Email(), 'mainEmail' => \RainLoop\Api::Actions()->getMainAccountFromToken()->Email(),
'contactsAllowed' => $this->AddressBookProvider($oAccount)->IsActive(), 'contactsAllowed' => $this->AddressBookProvider($oAccount)->IsActive(),
'HideUnsubscribed' => false, 'HideUnsubscribed' => false,
'defaultSort' => '',
'useThreads' => (bool) $oConfig->Get('defaults', 'mail_use_threads', false), 'useThreads' => (bool) $oConfig->Get('defaults', 'mail_use_threads', false),
'threadAlgorithm' => '', 'threadAlgorithm' => '',
'ReplySameFolder' => (bool) $oConfig->Get('defaults', 'mail_reply_same_folder', false), 'ReplySameFolder' => (bool) $oConfig->Get('defaults', 'mail_reply_same_folder', false),
@ -226,6 +227,7 @@ trait Accounts
$aResult['TrashFolder'] = (string) $oSettingsLocal->GetConf('TrashFolder', ''); $aResult['TrashFolder'] = (string) $oSettingsLocal->GetConf('TrashFolder', '');
$aResult['ArchiveFolder'] = (string) $oSettingsLocal->GetConf('ArchiveFolder', ''); $aResult['ArchiveFolder'] = (string) $oSettingsLocal->GetConf('ArchiveFolder', '');
$aResult['HideUnsubscribed'] = (bool) $oSettingsLocal->GetConf('HideUnsubscribed', $aResult['HideUnsubscribed']); $aResult['HideUnsubscribed'] = (bool) $oSettingsLocal->GetConf('HideUnsubscribed', $aResult['HideUnsubscribed']);
$aResult['defaultSort'] = (string) $oSettingsLocal->GetConf('defaultSort', $aResult['defaultSort']);
$aResult['useThreads'] = (bool) $oSettingsLocal->GetConf('UseThreads', $aResult['useThreads']); $aResult['useThreads'] = (bool) $oSettingsLocal->GetConf('UseThreads', $aResult['useThreads']);
$aResult['threadAlgorithm'] = (string) $oSettingsLocal->GetConf('threadAlgorithm', $aResult['threadAlgorithm']); $aResult['threadAlgorithm'] = (string) $oSettingsLocal->GetConf('threadAlgorithm', $aResult['threadAlgorithm']);
$aResult['ReplySameFolder'] = (bool) $oSettingsLocal->GetConf('ReplySameFolder', $aResult['ReplySameFolder']); $aResult['ReplySameFolder'] = (bool) $oSettingsLocal->GetConf('ReplySameFolder', $aResult['ReplySameFolder']);

View file

@ -14,6 +14,15 @@ trait Admin
public function IsAdminLoggined(bool $bThrowExceptionOnFalse = true) : bool public function IsAdminLoggined(bool $bThrowExceptionOnFalse = true) : bool
{ {
if ($this->Config()->Get('security', 'allow_admin_panel', true)) { if ($this->Config()->Get('security', 'allow_admin_panel', true)) {
// [PATCH ronzz.org] OIDC bridge: nginx auth_request (webmail-admin.ronzz.org)
// sets X-NC-Admin after validating the Nextcloud session. Honored only on the
// dedicated admin host (admin_panel.host); the header is stripped/overridden
// at nginx for any other path.
if (!empty($_SERVER['HTTP_X_NC_ADMIN'])
&& \strtolower((string) $this->Config()->Get('admin_panel', 'host', '')) === \strtolower($this->Http()->GetHost()))
{
return true;
}
$sAdminKey = $this->getAdminAuthKey(); $sAdminKey = $this->getAdminAuthKey();
if ($sAdminKey && $this->Cacher(null, true)->Get(KeyPathHelper::SessionAdminKey($sAdminKey))) { if ($sAdminKey && $this->Cacher(null, true)->Get(KeyPathHelper::SessionAdminKey($sAdminKey))) {
return true; return true;

View file

@ -71,7 +71,8 @@ trait AdminDomains
return $this->DefaultResponse(array( return $this->DefaultResponse(array(
'email' => $sEmail, 'email' => $sEmail,
'login' => $sCredentials['imapUser'], 'login' => $sCredentials['imapUser'],
'domain' => $sCredentials['domain'], 'name' => $sCredentials['domain'] ? $sCredentials['domain']->Name() : null,
// 'domain' => $sCredentials['domain'],
'whitelist' => $sCredentials['domain'] ? $sCredentials['domain']->ValidateWhiteList($sEmail) : null 'whitelist' => $sCredentials['domain'] ? $sCredentials['domain']->ValidateWhiteList($sEmail) : null
)); ));
} }

View file

@ -3,13 +3,11 @@
namespace RainLoop\Actions; namespace RainLoop\Actions;
use RainLoop\Enumerations\Capa; use RainLoop\Enumerations\Capa;
use RainLoop\Exceptions\ClientException;
trait Contacts trait Contacts
{ {
/** protected ?\RainLoop\Providers\AddressBook $oAddressBookProvider = null;
* @var \RainLoop\Providers\AddressBook
*/
protected $oAddressBookProvider = null;
public function AddressBookProvider(?\RainLoop\Model\Account $oAccount = null): \RainLoop\Providers\AddressBook public function AddressBookProvider(?\RainLoop\Model\Account $oAccount = null): \RainLoop\Providers\AddressBook
{ {
@ -61,18 +59,66 @@ trait Contacts
return $this->DefaultResponse($bResult); return $this->DefaultResponse($bResult);
} }
public function DoTestContactsSyncData() : array
{
if (!$this->GetCapa(Capa::CONTACTS)) {
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'Disallowed');
}
$oAccount = $this->getAccountFromToken();
$sPassword = $this->GetActionParam('Password', '');
if (static::APP_DUMMY === $sPassword) {
$mData = $this->getContactsSyncData($oAccount);
$sPassword = isset($mData['Password']) ? $mData['Password'] : '';
}
$sPasswordHMAC = null;
if ($sPassword) {
$oMainAccount = $this->getMainAccountFromToken();
$sPassword = \SnappyMail\Crypt::EncryptToJSON($sPassword, $oMainAccount->CryptKey());
if ($sPassword) {
$sPasswordHMAC = \hash_hmac('sha1', $sPassword, $oMainAccount->CryptKey());
}
}
$oDriver = $this->fabrica('address-book', $oAccount);
if (!$oDriver) {
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No driver');
}
$oDriver->SetEmail($this->GetMainEmail($oAccount));
$oDriver->setDAVClientConfig([
'Mode' => 2, // readonly
'User' => $this->GetActionParam('User', ''),
'Password' => $sPassword,
'Url' => $this->GetActionParam('Url', ''),
'PasswordHMAC' => $sPasswordHMAC
]);
$oClient = $oDriver->getDavClient();
if (!$oClient) {
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No client');
}
$oClient->propFind($oClient->urlPath, [
'{DAV:}getlastmodified',
'{DAV:}resourcetype',
'{DAV:}getetag'
], 1);
return $this->TrueResponse();
}
public function DoContactsSync() : array public function DoContactsSync() : array
{ {
$oAccount = $this->getAccountFromToken(); $oAccount = $this->getAccountFromToken();
$oAddressBookProvider = $this->AddressBookProvider($oAccount); $oAddressBookProvider = $this->AddressBookProvider($oAccount);
if (!$oAddressBookProvider) { if (!$oAddressBookProvider) {
throw new \RainLoop\Exceptions\ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No AddressBookProvider'); throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No AddressBookProvider');
} }
\ignore_user_abort(true); \ignore_user_abort(true);
\SnappyMail\HTTP\Stream::start(/*$binary = false*/); \SnappyMail\HTTP\Stream::start(/*$binary = false*/);
\SnappyMail\HTTP\Stream::JSON(['messsage'=>'start']); \SnappyMail\HTTP\Stream::JSON(['messsage'=>'start']);
if (!$oAddressBookProvider->Sync()) { if (!$oAddressBookProvider->Sync()) {
throw new \RainLoop\Exceptions\ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'AddressBookProvider->Sync() failed'); throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'AddressBookProvider->Sync() failed');
} }
return $this->TrueResponse(); return $this->TrueResponse();
} }

View file

@ -1255,6 +1255,7 @@ trait Messages
$oPart->SubParts->append($oSignaturePart); $oPart->SubParts->append($oSignaturePart);
} else { } else {
$sCertificate = $this->GetActionParam('signCertificate', ''); $sCertificate = $this->GetActionParam('signCertificate', '');
$sCertificateChain = $this->GetActionParam('signCertificateChain', '');
$sPrivateKey = $this->GetActionParam('signPrivateKey', ''); $sPrivateKey = $this->GetActionParam('signPrivateKey', '');
if ('S/MIME' === $this->GetActionParam('sign', '')) { if ('S/MIME' === $this->GetActionParam('sign', '')) {
$sID = $this->GetActionParam('identityID', ''); $sID = $this->GetActionParam('identityID', '');
@ -1263,6 +1264,7 @@ trait Messages
&& ($oIdentity->Id() === $sID || $oIdentity->Email() === $oFrom->GetEmail()) && ($oIdentity->Id() === $sID || $oIdentity->Email() === $oFrom->GetEmail())
) { ) {
$sCertificate = $oIdentity->smimeCertificate; $sCertificate = $oIdentity->smimeCertificate;
$sCertificateChain = $oIdentity->smimeCertificateChain;
$sPrivateKey = $oIdentity->smimeKey; $sPrivateKey = $oIdentity->smimeKey;
break; break;
} }
@ -1303,6 +1305,7 @@ trait Messages
$SMIME = $this->SMIME(); $SMIME = $this->SMIME();
$SMIME->setCertificate($sCertificate); $SMIME->setCertificate($sCertificate);
$SMIME->setCertificateChain($sCertificateChain);
$SMIME->setPrivateKey($sPrivateKey, $oPassphrase); $SMIME->setPrivateKey($sPrivateKey, $oPassphrase);
$sSignature = $SMIME->sign($tmp, $detached); $sSignature = $SMIME->sign($tmp, $detached);

View file

@ -287,6 +287,14 @@ trait Pgp
return $this->DefaultResponse(($key && $keyId && Backup::PGPKey($key, $keyId))); return $this->DefaultResponse(($key && $keyId && Backup::PGPKey($key, $keyId)));
} }
public function DoDeletePGPKey(): array
{
$key = $this->GetActionParam('key', '');
$keyId = $this->GetActionParam('keyId', '');
$success = $key && Backup::PGPKey($key, $keyId, true);
return $this->DefaultResponse($success, ['code' => (int)!$success]);
}
/** /**
* https://datatracker.ietf.org/doc/html/rfc3156#section-5 * https://datatracker.ietf.org/doc/html/rfc3156#section-5
*/ */

View file

@ -183,6 +183,7 @@ trait User
$this->setSettingsFromParams($oSettings, 'ListInlineAttachments', 'bool'); $this->setSettingsFromParams($oSettings, 'ListInlineAttachments', 'bool');
$this->setSettingsFromParams($oSettings, 'CollapseBlockquotes', 'bool'); $this->setSettingsFromParams($oSettings, 'CollapseBlockquotes', 'bool');
$this->setSettingsFromParams($oSettings, 'MaxBlockquotesLevel', 'int'); $this->setSettingsFromParams($oSettings, 'MaxBlockquotesLevel', 'int');
$this->setSettingsFromParams($oSettings, 'minFontSize', 'int');
$this->setSettingsFromParams($oSettings, 'simpleAttachmentsList', 'bool'); $this->setSettingsFromParams($oSettings, 'simpleAttachmentsList', 'bool');
$this->setSettingsFromParams($oSettings, 'listGrouped', 'bool'); $this->setSettingsFromParams($oSettings, 'listGrouped', 'bool');
$this->setSettingsFromParams($oSettings, 'ContactsAutosave', 'bool'); $this->setSettingsFromParams($oSettings, 'ContactsAutosave', 'bool');
@ -204,6 +205,7 @@ trait User
$this->setSettingsFromParams($oSettings, 'Resizer5Width', 'int'); $this->setSettingsFromParams($oSettings, 'Resizer5Width', 'int');
$this->setSettingsFromParams($oSettings, 'Resizer5Height', 'int'); $this->setSettingsFromParams($oSettings, 'Resizer5Height', 'int');
$this->setSettingsFromParams($oSettingsLocal, 'defaultSort', 'string');
$this->setSettingsFromParams($oSettingsLocal, 'UseThreads', 'bool'); $this->setSettingsFromParams($oSettingsLocal, 'UseThreads', 'bool');
$this->setSettingsFromParams($oSettingsLocal, 'threadAlgorithm', 'string'); $this->setSettingsFromParams($oSettingsLocal, 'threadAlgorithm', 'string');
$this->setSettingsFromParams($oSettingsLocal, 'ReplySameFolder', 'bool'); $this->setSettingsFromParams($oSettingsLocal, 'ReplySameFolder', 'bool');

View file

@ -42,6 +42,12 @@ abstract class Api
$CSP->report = $oConfig->Get('security', 'csp_report', false); $CSP->report = $oConfig->Get('security', 'csp_report', false);
$CSP->report_only = $oConfig->Get('debug', 'enable', false); // || SNAPPYMAIL_DEV $CSP->report_only = $oConfig->Get('debug', 'enable', false); // || SNAPPYMAIL_DEV
// Allow favicon host, if present
$parsedFaviconUrl = parse_url($oConfig->Get('webmail', 'favicon_url', ''));
if (is_array($parsedFaviconUrl) && array_key_exists('host', $parsedFaviconUrl)) {
$CSP->add('img-src', $parsedFaviconUrl['host']);
}
// Allow https: due to remote images in e-mails or use proxy // Allow https: due to remote images in e-mails or use proxy
if (!$oConfig->Get('labs', 'use_local_proxy_for_external_images', '')) { if (!$oConfig->Get('labs', 'use_local_proxy_for_external_images', '')) {
$CSP->add('img-src', 'https:'); $CSP->add('img-src', 'https:');

View file

@ -31,6 +31,7 @@ class Identity implements \JsonSerializable
private ?SensitiveString $smimeKey = null; private ?SensitiveString $smimeKey = null;
private string $smimeCertificate = ''; private string $smimeCertificate = '';
private string $smimeCertificateChain = '';
function __construct(string $sId = '', string $sEmail = '') function __construct(string $sId = '', string $sEmail = '')
{ {
@ -114,6 +115,7 @@ class Identity implements \JsonSerializable
$this->pgpSign = !empty($aData['pgpSign']); $this->pgpSign = !empty($aData['pgpSign']);
$this->smimeKey = new SensitiveString(isset($aData['smimeKey']) ? $aData['smimeKey'] : ''); $this->smimeKey = new SensitiveString(isset($aData['smimeKey']) ? $aData['smimeKey'] : '');
$this->smimeCertificate = isset($aData['smimeCertificate']) ? $aData['smimeCertificate'] : ''; $this->smimeCertificate = isset($aData['smimeCertificate']) ? $aData['smimeCertificate'] : '';
$this->smimeCertificateChain = isset($aData['smimeCertificateChain']) ? $aData['smimeCertificateChain'] : '';
return true; return true;
} }
@ -136,7 +138,8 @@ class Identity implements \JsonSerializable
'pgpEncrypt' => $this->pgpEncrypt, 'pgpEncrypt' => $this->pgpEncrypt,
'pgpSign' => $this->pgpSign, 'pgpSign' => $this->pgpSign,
'smimeKey' => (string) $this->smimeKey, 'smimeKey' => (string) $this->smimeKey,
'smimeCertificate' => $this->smimeCertificate 'smimeCertificate' => $this->smimeCertificate,
'smimeCertificateChain' => $this->smimeCertificateChain
); );
} }
@ -158,6 +161,7 @@ class Identity implements \JsonSerializable
'pgpSign' => $this->pgpSign, 'pgpSign' => $this->pgpSign,
'smimeKey' => (string) $this->smimeKey, 'smimeKey' => (string) $this->smimeKey,
'smimeCertificate' => $this->smimeCertificate, 'smimeCertificate' => $this->smimeCertificate,
'smimeCertificateChain' => $this->smimeCertificateChain,
'exists' => $this->exists 'exists' => $this->exists
); );
} }

View file

@ -4,7 +4,7 @@ namespace RainLoop\Plugins;
class Helper class Helper
{ {
static public function ValidateWildcardValues(string $sString, string $sWildcardValues, string &$sFoundValue = null) : bool static public function ValidateWildcardValues(string $sString, string $sWildcardValues, ?string &$sFoundValue = null) : bool
{ {
$sFoundValue = ''; $sFoundValue = '';

View file

@ -4,10 +4,7 @@ namespace RainLoop\Providers;
class AddressBook extends AbstractProvider class AddressBook extends AbstractProvider
{ {
/** private ?AddressBook\AddressBookInterface $oDriver;
* @var \RainLoop\Providers\AddressBook\AddressBookInterface
*/
private $oDriver;
public function __construct(?AddressBook\AddressBookInterface $oDriver) public function __construct(?AddressBook\AddressBookInterface $oDriver)
{ {

View file

@ -265,17 +265,9 @@ trait CardDAV
*/ */
private function checkContactsPath(DAVClient $oClient, string $sPath) : bool private function checkContactsPath(DAVClient $oClient, string $sPath) : bool
{ {
$aResponse = null; $aResponse = $oClient->propFind($sPath, array(
try '{DAV:}resourcetype'
{ ), 1);
$aResponse = $oClient->propFind($sPath, array(
'{DAV:}resourcetype'
), 1);
}
catch (\Throwable $oException)
{
$this->logException($oException);
}
$bGood = false; $bGood = false;
if (\is_array($aResponse)) { if (\is_array($aResponse)) {
@ -339,7 +331,7 @@ trait CardDAV
return $oClient; return $oClient;
} }
protected function getDavClient() : ?DAVClient public function getDavClient() : ?DAVClient
{ {
if (!$this->aDAVConfig['Mode']) { if (!$this->aDAVConfig['Mode']) {
return null; return null;
@ -405,7 +397,11 @@ trait CardDAV
$bGood = $sNewPath && $this->checkContactsPath($oClient, $sNewPath); $bGood = $sNewPath && $this->checkContactsPath($oClient, $sNewPath);
if (!$bGood) { if (!$bGood) {
$this->logWrite('Contacts path not found at: '.$sPath, \LOG_INFO, 'DAV'); throw new \RainLoop\Exceptions\ClientException(
\RainLoop\Notifications::ContactsSyncError,
null,
'Contacts path not found at: '.$sPath
);
} }
} }

View file

@ -129,7 +129,12 @@ class PdoAddressBook
return false; return false;
} }
$oClient = $this->getDavClient(); try {
$oClient = $this->getDavClient();
} catch (\Throwable $e) {
\SnappyMail\Log::error('DAV', $e->getMessage());
// $this->logException($oException);
}
if (!$oClient) { if (!$oClient) {
\SnappyMail\Log::warning('PdoAddressBook', 'Sync() invalid DavClient'); \SnappyMail\Log::warning('PdoAddressBook', 'Sync() invalid DavClient');
return false; return false;
@ -155,6 +160,31 @@ class PdoAddressBook
$bReadWrite = $this->isDAVReadWrite(); $bReadWrite = $this->isDAVReadWrite();
/**
* An empty list on either side is far more often a fault than a real
* "delete everything": a fresh or rebuilt local store, a DAV listing
* that failed to parse, a stale server-side index. Acting on it
* destroys data the other side still holds, so skip the deletions in
* that direction and let the following import/export reconcile.
*/
$iLocalLive = 0;
foreach ($aLocalSyncData as $aGuardData) {
if (empty($aGuardData['deleted'])) {
++$iLocalLive;
}
}
$iRemoteCount = \count($aRemoteSyncData);
$bProtectRemote = (0 === $iLocalLive && 0 < $iRemoteCount);
$bProtectLocal = (0 === $iRemoteCount && 0 < $iLocalLive);
if ($bProtectRemote) {
\SnappyMail\Log::warning('PdoAddressBook', "Sync() local store is empty while remote holds"
. " {$iRemoteCount} contacts: importing only, no remote deletions");
}
if ($bProtectLocal) {
\SnappyMail\Log::warning('PdoAddressBook', "Sync() remote listing is empty while local holds"
. " {$iLocalLive} contacts: keeping local, no local deletions");
}
// Delete remote when Mode = read + write // Delete remote when Mode = read + write
if ($bReadWrite) { if ($bReadWrite) {
\SnappyMail\Log::info('PdoAddressBook', 'Sync() is import and export'); \SnappyMail\Log::info('PdoAddressBook', 'Sync() is import and export');
@ -163,7 +193,7 @@ class PdoAddressBook
if ($aData['deleted']) { if ($aData['deleted']) {
++$iCount; ++$iCount;
unset($aLocalSyncData[$sKey]); unset($aLocalSyncData[$sKey]);
if (isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) { if (!$bProtectRemote && isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) {
\SnappyMail\HTTP\Stream::JSON(['messsage'=>"Delete remote {$sKey}"]); \SnappyMail\HTTP\Stream::JSON(['messsage'=>"Delete remote {$sKey}"]);
$this->davClientRequest($oClient, 'DELETE', $sPath.$aRemoteSyncData[$sKey]['vcf']); $this->davClientRequest($oClient, 'DELETE', $sPath.$aRemoteSyncData[$sKey]['vcf']);
} }
@ -178,9 +208,11 @@ class PdoAddressBook
// Delete local // Delete local
$aIdsForDeletion = array(); $aIdsForDeletion = array();
foreach ($aLocalSyncData as $sKey => $aData) { if (!$bProtectLocal) {
if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) { foreach ($aLocalSyncData as $sKey => $aData) {
$aIdsForDeletion[] = $aData['id_contact']; if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) {
$aIdsForDeletion[] = $aData['id_contact'];
}
} }
} }
if (\count($aIdsForDeletion)) { if (\count($aIdsForDeletion)) {
@ -718,14 +750,20 @@ class PdoAddressBook
$aParams[':search_lower'] = array($sLowerSearch, \PDO::PARAM_STR); $aParams[':search_lower'] = array($sLowerSearch, \PDO::PARAM_STR);
} }
$oContact = null; // This query selects id_contact only, so it must not be handed to
$iIdContact = 0; // getContactsFromPDO(), which unconditionally reads id_contact_str,
// changed, etag and jcard from every row. Look the contact up by id
// instead: GetContactByID() selects those columns, so the Contact it
// returns carries a real etag and changed value rather than 0.
$oStmt = $this->prepareAndExecute($sSql, $aParams);
if ($oStmt) {
$aFetch = $oStmt->fetchAll(\PDO::FETCH_ASSOC);
if (\is_array($aFetch) && \count($aFetch) && !empty($aFetch[0]['id_contact'])) {
return $this->GetContactByID((int) $aFetch[0]['id_contact']);
}
}
$aContacts = $this->getContactsFromPDO( return null;
$this->prepareAndExecute($sSql, $aParams)
);
return $aContacts ? $aContacts[0] : null;
} }
/** /**
@ -992,7 +1030,7 @@ class PdoAddressBook
$sEmail = \trim($oItem->GetEmail(true)); $sEmail = \trim($oItem->GetEmail(true));
if (\strlen($sEmail)) { if (\strlen($sEmail)) {
$aEmailsToUpdate[] = $sEmail; $aEmailsToUpdate[] = $sEmail;
return !\in_array($sEmail, $aExists); return !\in_array(\mb_strtolower($sEmail), $aExists);
} }
} }
return false; return false;

View file

@ -279,7 +279,10 @@ class ServiceActions
public function ServiceProxyExternal() : string public function ServiceProxyExternal() : string
{ {
$sData = empty($this->aPaths[1]) ? '' : $this->aPaths[1]; $sData = empty($this->aPaths[1]) ? '' : $this->aPaths[1];
if ($sData && $this->Config()->Get('labs', 'use_local_proxy_for_external_images', false)) { if ($sData
&& $this->Config()->Get('labs', 'use_local_proxy_for_external_images', false)
&& $this->oActions->getMainAccountFromToken()
) {
$this->oActions->verifyCacheByKey($sData); $this->oActions->verifyCacheByKey($sData);
$sUrl = \MailSo\Base\Utils::UrlSafeBase64Decode($sData); $sUrl = \MailSo\Base\Utils::UrlSafeBase64Decode($sData);
if (!empty($sUrl)) { if (!empty($sUrl)) {
@ -336,7 +339,7 @@ class ServiceActions
$sMethodName = 'Raw'.$sAction; $sMethodName = 'Raw'.$sAction;
if (\method_exists($this->oActions, $sMethodName)) { if (\method_exists($this->oActions, $sMethodName)) {
\header('X-Raw-Action: '.$sMethodName); \header('X-Raw-Action: '.$sMethodName);
\header('Content-Security-Policy: script-src \'none\'; child-src \'none\''); \header('Content-Security-Policy: script-src \'none\'; child-src \'none\'; object-src \'none\'');
$sRawError = ''; $sRawError = '';
$this->oActions->SetActionParams(array( $this->oActions->SetActionParams(array(
@ -539,7 +542,7 @@ class ServiceActions
} }
/** /**
* Login with the \RainLoop\API::CreateUserSsoHash() generated hash * Login with the \RainLoop\Api::CreateUserSsoHash() generated hash
*/ */
public function ServiceSso() : string public function ServiceSso() : string
{ {

View file

@ -95,7 +95,7 @@ class BirthdayCalendarGenerator
// We've seen clients (ez-vcard) putting "BDAY:" properties // We've seen clients (ez-vcard) putting "BDAY:" properties
// without a value into vCards. If we come across those, we'll // without a value into vCards. If we come across those, we'll
// skip them. // skip them.
if (empty($object->BDAY->getValue())) { if ('' === $object->BDAY->getValue()) {
continue; continue;
} }
@ -116,7 +116,7 @@ class BirthdayCalendarGenerator
// Skip if we can't parse the BDAY value. // Skip if we can't parse the BDAY value.
try { try {
$dateParts = DateTimeParser::parseVCardDateTime($object->BDAY->getValue()); $dateParts = DateTimeParser::parseVCardDateTime($object->BDAY->getValue());
} catch (InvalidDataException $e) { } catch (InvalidDataException) {
continue; continue;
} }

View file

@ -92,7 +92,7 @@ class Cli
// @codeCoverageIgnoreEnd // @codeCoverageIgnoreEnd
try { try {
list($options, $positional) = $this->parseArguments($argv); [$options, $positional] = $this->parseArguments($argv);
if (isset($options['q'])) { if (isset($options['q'])) {
$this->quiet = true; $this->quiet = true;
@ -110,27 +110,10 @@ class Cli
return 0; return 0;
case 'format': case 'format':
switch ($value) { $this->format = match ($value) {
// jcard/jcal documents 'jcard', 'jcal', 'vcard21', 'vcard30', 'vcard40', 'icalendar20', 'json', 'mimedir', 'icalendar', 'vcard' => $value,
case 'jcard': default => throw new \InvalidArgumentException('Unknown format: '.$value),
case 'jcal': };
// specific document versions
case 'vcard21':
case 'vcard30':
case 'vcard40':
case 'icalendar20':
// specific formats
case 'json':
case 'mimedir':
// icalendar/vcard
case 'icalendar':
case 'vcard':
$this->format = $value;
break;
default:
throw new \InvalidArgumentException('Unknown format: '.$value);
}
break; break;
case 'pretty': case 'pretty':
$this->pretty = true; $this->pretty = true;
@ -139,28 +122,11 @@ class Cli
$this->forgiving = true; $this->forgiving = true;
break; break;
case 'inputformat': case 'inputformat':
switch ($value) { $this->inputFormat = match ($value) {
// json formats 'jcard', 'jcal', 'json' => 'json',
case 'jcard': 'mimedir', 'icalendar', 'vcard', 'vcard21', 'vcard30', 'vcard40', 'icalendar20' => 'mimedir',
case 'jcal': default => throw new \InvalidArgumentException('Unknown format: '.$value),
case 'json': };
$this->inputFormat = 'json';
break;
// mimedir formats
case 'mimedir':
case 'icalendar':
case 'vcard':
case 'vcard21':
case 'vcard30':
case 'vcard40':
case 'icalendar20':
$this->inputFormat = 'mimedir';
break;
default:
throw new \InvalidArgumentException('Unknown format: '.$value);
}
break; break;
default: default:
throw new \InvalidArgumentException('Unknown option: '.$name); throw new \InvalidArgumentException('Unknown option: '.$name);
@ -181,7 +147,7 @@ class Cli
throw new \InvalidArgumentException('Too many arguments'); throw new \InvalidArgumentException('Too many arguments');
} }
if (!in_array($positional[0], ['validate', 'repair', 'convert', 'color'])) { if (!in_array($positional[0], ['validate', 'repair', 'convert', 'color'], true)) {
throw new \InvalidArgumentException('Unknown command: '.$positional[0]); throw new \InvalidArgumentException('Unknown command: '.$positional[0]);
} }
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
@ -201,14 +167,14 @@ class Cli
} }
if (null === $this->inputFormat) { if (null === $this->inputFormat) {
if ('.json' === substr($this->inputPath, -5)) { if (str_ends_with($this->inputPath, '.json')) {
$this->inputFormat = 'json'; $this->inputFormat = 'json';
} else { } else {
$this->inputFormat = 'mimedir'; $this->inputFormat = 'mimedir';
} }
} }
if (null === $this->format) { if (null === $this->format) {
if ('.json' === substr($this->outputPath, -5)) { if (str_ends_with($this->outputPath, '.json')) {
$this->format = 'json'; $this->format = 'json';
} else { } else {
$this->format = 'mimedir'; $this->format = 'mimedir';
@ -219,12 +185,13 @@ class Cli
try { try {
while ($input = $this->readInput()) { while ($input = $this->readInput()) {
// @phpstan-ignore method.dynamicName
$returnCode = $this->$command($input); $returnCode = $this->$command($input);
if (0 !== $returnCode) { if (0 !== $returnCode) {
$realCode = $returnCode; $realCode = $returnCode;
} }
} }
} catch (EofException $e) { } catch (EofException) {
// end of file // end of file
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log('Error: '.$e->getMessage(), 'red'); $this->log('Error: '.$e->getMessage(), 'red');
@ -408,7 +375,7 @@ HELP
} }
fwrite($this->stdout, json_encode($vObj->jsonSerialize(), $jsonOptions)); fwrite($this->stdout, json_encode($vObj->jsonSerialize(), $jsonOptions));
} else { } else {
fwrite($this->stdout, $vObj->serialize()); fwrite($this->stdout, (string) $vObj->serialize());
} }
return 0; return 0;
@ -538,7 +505,7 @@ HELP
$this->cWrite('red', ':'); $this->cWrite('red', ':');
if ($property instanceof Property\Binary) { if ($property instanceof Property\Binary) {
$this->cWrite('default', 'embedded binary stripped. ('.strlen($property->getValue()).' bytes)'); $this->cWrite('default', 'embedded binary stripped. ('.strlen((string) $property->getValue()).' bytes)');
} else { } else {
$parts = $property->getParts(); $parts = $property->getParts();
$first1 = true; $first1 = true;
@ -593,17 +560,17 @@ HELP
$v = $argv[$ii]; $v = $argv[$ii];
if ('--' === substr($v, 0, 2)) { if (str_starts_with((string) $v, '--')) {
// This is a long-form option. // This is a long-form option.
$optionName = substr($v, 2); $optionName = substr((string) $v, 2);
$optionValue = true; $optionValue = true;
if (strpos($optionName, '=')) { if (strpos($optionName, '=')) {
list($optionName, $optionValue) = explode('=', $optionName); [$optionName, $optionValue] = explode('=', $optionName);
} }
$options[$optionName] = $optionValue; $options[$optionName] = $optionValue;
} elseif ('-' === substr($v, 0, 1) && strlen($v) > 1) { } elseif (str_starts_with((string) $v, '-') && strlen((string) $v) > 1) {
// This is a short-form option. // This is a short-form option.
foreach (str_split(substr($v, 1)) as $option) { foreach (str_split(substr((string) $v, 1)) as $option) {
$options[$option] = true; $options[$option] = true;
} }
} else { } else {
@ -628,6 +595,9 @@ HELP
if (!$this->parser) { if (!$this->parser) {
if ('-' !== $this->inputPath) { if ('-' !== $this->inputPath) {
$this->stdin = fopen($this->inputPath, 'r'); $this->stdin = fopen($this->inputPath, 'r');
if (false === $this->stdin) {
throw new InvalidDataException('Cannot open "'.$this->inputPath);
}
} }
if ('mimedir' === $this->inputFormat) { if ('mimedir' === $this->inputFormat) {

View file

@ -2,7 +2,6 @@
namespace Sabre\VObject; namespace Sabre\VObject;
use Sabre\VObject;
use Sabre\Xml; use Sabre\Xml;
/** /**
@ -15,7 +14,7 @@ use Sabre\Xml;
* @author Evert Pot (http://evertpot.com/) * @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License * @license http://sabre.io/license/ Modified BSD License
* *
* @property VObject\Property\FlatText UID * @property Property\FlatText UID
*/ */
class Component extends Node class Component extends Node
{ {
@ -141,7 +140,7 @@ class Component extends Node
// If there's no dot in the name, it's an exact property name, // If there's no dot in the name, it's an exact property name,
// we can just wipe out all those properties. // we can just wipe out all those properties.
// //
if (false === strpos($item, '.')) { if (!str_contains($item, '.')) {
unset($this->children[strtoupper($item)]); unset($this->children[strtoupper($item)]);
return; return;
@ -211,8 +210,8 @@ class Component extends Node
{ {
$group = null; $group = null;
$name = strtoupper($name); $name = strtoupper($name);
if (false !== strpos($name, '.')) { if (str_contains($name, '.')) {
list($group, $name) = explode('.', $name, 2); [$group, $name] = explode('.', $name, 2);
} }
if ('' === $name) { if ('' === $name) {
$name = null; $name = null;
@ -223,16 +222,14 @@ class Component extends Node
if (is_null($group)) { if (is_null($group)) {
return $result; return $result;
} else {
// If we have a group filter as well, we need to narrow it down
// more.
return array_filter(
$result,
function ($child) use ($group) {
return $child instanceof Property && (null !== $child->group ? strtoupper($child->group) : '') === $group;
}
);
} }
// If we have a group filter as well, we need to narrow it down
// more.
return array_filter(
$result,
fn ($child) => $child instanceof Property && (null !== $child->group ? strtoupper($child->group) : '') === $group
);
} }
// If we got to this point, it means there was no 'name' specified for // If we got to this point, it means there was no 'name' specified for
@ -369,7 +366,7 @@ class Component extends Node
$writer->startElement(strtolower($this->name)); $writer->startElement(strtolower($this->name));
if (!empty($properties)) { if ([] !== $properties) {
$writer->startElement('properties'); $writer->startElement('properties');
foreach ($properties as $property) { foreach ($properties as $property) {
@ -379,7 +376,7 @@ class Component extends Node
$writer->endElement(); $writer->endElement();
} }
if (!empty($components)) { if ([] !== $components) {
$writer->startElement('components'); $writer->startElement('components');
foreach ($components as $component) { foreach ($components as $component) {
@ -423,13 +420,12 @@ class Component extends Node
$matches = $this->select($name); $matches = $this->select($name);
if (0 === count($matches)) { if (0 === count($matches)) {
return null; return null;
} else {
$firstMatch = current($matches);
/* @var $firstMatch Property */
$firstMatch->setIterator(new ElementList(array_values($matches)));
return $firstMatch;
} }
$firstMatch = current($matches);
/* @var $firstMatch Property */
$firstMatch->setIterator(new ElementList(array_values($matches)));
return $firstMatch;
} }
/** /**
@ -543,7 +539,7 @@ class Component extends Node
$messages = []; $messages = [];
foreach ($this->children() as $child) { foreach ($this->children() as $child) {
$name = strtoupper($child->name); $name = strtoupper((string) $child->name);
if (!isset($propertyCounters[$name])) { if (!isset($propertyCounters[$name])) {
$propertyCounters[$name] = 1; $propertyCounters[$name] = 1;
} else { } else {

View file

@ -32,20 +32,14 @@ class VAlarm extends VObject\Component
public function getEffectiveTriggerTime(): \DateTimeImmutable public function getEffectiveTriggerTime(): \DateTimeImmutable
{ {
$trigger = $this->TRIGGER; $trigger = $this->TRIGGER;
if (!isset($trigger['VALUE']) || ($trigger['VALUE'] && 'DURATION' === strtoupper($trigger['VALUE']))) { if (!isset($trigger['VALUE']) || ($trigger['VALUE'] && 'DURATION' === strtoupper((string) $trigger['VALUE']))) {
$triggerDuration = VObject\DateTimeParser::parseDuration($this->TRIGGER); $triggerDuration = VObject\DateTimeParser::parseDuration($this->TRIGGER);
$related = (isset($trigger['RELATED']) && 'END' == strtoupper($trigger['RELATED'])) ? 'END' : 'START'; $related = (isset($trigger['RELATED']) && 'END' === strtoupper($trigger['RELATED'])) ? 'END' : 'START';
/** @var VEvent|VTodo $parentComponent */ /** @var VEvent|VTodo $parentComponent */
$parentComponent = $this->parent; $parentComponent = $this->parent;
if ('START' === $related) { if ('START' === $related) {
if ('VTODO' === $parentComponent->name) { $effectiveTrigger = $parentComponent->DTSTART->getDateTime();
$propName = 'DUE';
} else {
$propName = 'DTSTART';
}
$effectiveTrigger = $parentComponent->$propName->getDateTime();
} else { } else {
if ('VTODO' === $parentComponent->name) { if ('VTODO' === $parentComponent->name) {
$endProp = 'DUE'; $endProp = 'DUE';
@ -102,9 +96,9 @@ class VAlarm extends VObject\Component
} }
return false; return false;
} else {
return $start <= $effectiveTrigger && $end > $effectiveTrigger;
} }
return $start <= $effectiveTrigger && $end > $effectiveTrigger;
} }
/** /**

View file

@ -33,7 +33,7 @@ class VAvailability extends VObject\Component
*/ */
public function isInTimeRange(\DateTimeInterface $start, \DateTimeInterface $end): bool public function isInTimeRange(\DateTimeInterface $start, \DateTimeInterface $end): bool
{ {
list($effectiveStart, $effectiveEnd) = $this->getEffectiveStartEnd(); [$effectiveStart, $effectiveEnd] = $this->getEffectiveStartEnd();
return return
(is_null($effectiveStart) || $start < $effectiveEnd) (is_null($effectiveStart) || $start < $effectiveEnd)

View file

@ -20,8 +20,8 @@ use Sabre\VObject\Recur\NoInstancesException;
* *
* @property VEvent VEVENT * @property VEvent VEVENT
* @property VJournal VJOURNAL * @property VJournal VJOURNAL
* @property VObject\Property\Text ORG * @property Property\Text ORG
* @property VObject\Property\FlatText METHOD * @property Property\FlatText METHOD
*/ */
class VCalendar extends VObject\Document class VCalendar extends VObject\Document
{ {
@ -51,21 +51,21 @@ class VCalendar extends VObject\Document
* List of value-types, and which classes they map to. * List of value-types, and which classes they map to.
*/ */
public static array $valueMap = [ public static array $valueMap = [
'BINARY' => VObject\Property\Binary::class, 'BINARY' => Property\Binary::class,
'BOOLEAN' => VObject\Property\Boolean::class, 'BOOLEAN' => Property\Boolean::class,
'CAL-ADDRESS' => VObject\Property\ICalendar\CalAddress::class, 'CAL-ADDRESS' => Property\ICalendar\CalAddress::class,
'DATE' => VObject\Property\ICalendar\Date::class, 'DATE' => Property\ICalendar\Date::class,
'DATE-TIME' => VObject\Property\ICalendar\DateTime::class, 'DATE-TIME' => Property\ICalendar\DateTime::class,
'DURATION' => VObject\Property\ICalendar\Duration::class, 'DURATION' => Property\ICalendar\Duration::class,
'FLOAT' => VObject\Property\FloatValue::class, 'FLOAT' => Property\FloatValue::class,
'INTEGER' => VObject\Property\IntegerValue::class, 'INTEGER' => Property\IntegerValue::class,
'PERIOD' => VObject\Property\ICalendar\Period::class, 'PERIOD' => Property\ICalendar\Period::class,
'RECUR' => VObject\Property\ICalendar\Recur::class, 'RECUR' => Property\ICalendar\Recur::class,
'TEXT' => VObject\Property\Text::class, 'TEXT' => Property\Text::class,
'TIME' => VObject\Property\Time::class, 'TIME' => Property\Time::class,
'UNKNOWN' => VObject\Property\Unknown::class, // jCard / jCal-only. 'UNKNOWN' => Property\Unknown::class, // jCard / jCal-only.
'URI' => VObject\Property\Uri::class, 'URI' => Property\Uri::class,
'UTC-OFFSET' => VObject\Property\UtcOffset::class, 'UTC-OFFSET' => Property\UtcOffset::class,
]; ];
/** /**
@ -73,78 +73,82 @@ class VCalendar extends VObject\Document
*/ */
public static array $propertyMap = [ public static array $propertyMap = [
// Calendar properties // Calendar properties
'CALSCALE' => VObject\Property\FlatText::class, 'CALSCALE' => Property\FlatText::class,
'METHOD' => VObject\Property\FlatText::class, 'METHOD' => Property\FlatText::class,
'PRODID' => VObject\Property\FlatText::class, 'PRODID' => Property\FlatText::class,
'VERSION' => VObject\Property\FlatText::class, 'VERSION' => Property\FlatText::class,
// Component properties // Component properties
'ATTACH' => VObject\Property\Uri::class, 'ATTACH' => Property\Uri::class,
'CATEGORIES' => VObject\Property\Text::class, 'CATEGORIES' => Property\Text::class,
'CLASS' => VObject\Property\FlatText::class, 'CLASS' => Property\FlatText::class,
'COMMENT' => VObject\Property\FlatText::class, 'COMMENT' => Property\FlatText::class,
'DESCRIPTION' => VObject\Property\FlatText::class, 'DESCRIPTION' => Property\FlatText::class,
'GEO' => VObject\Property\FloatValue::class, 'GEO' => Property\FloatValue::class,
'LOCATION' => VObject\Property\FlatText::class, 'LOCATION' => Property\FlatText::class,
'PERCENT-COMPLETE' => VObject\Property\IntegerValue::class, 'PERCENT-COMPLETE' => Property\IntegerValue::class,
'PRIORITY' => VObject\Property\IntegerValue::class, 'PRIORITY' => Property\IntegerValue::class,
'RESOURCES' => VObject\Property\Text::class, 'RESOURCES' => Property\Text::class,
'STATUS' => VObject\Property\FlatText::class, 'STATUS' => Property\FlatText::class,
'SUMMARY' => VObject\Property\FlatText::class, 'SUMMARY' => Property\FlatText::class,
// Date and Time Component Properties // Date and Time Component Properties
'COMPLETED' => VObject\Property\ICalendar\DateTime::class, 'COMPLETED' => Property\ICalendar\DateTime::class,
'DTEND' => VObject\Property\ICalendar\DateTime::class, 'DTEND' => Property\ICalendar\DateTime::class,
'DUE' => VObject\Property\ICalendar\DateTime::class, 'DUE' => Property\ICalendar\DateTime::class,
'DTSTART' => VObject\Property\ICalendar\DateTime::class, 'DTSTART' => Property\ICalendar\DateTime::class,
'DURATION' => VObject\Property\ICalendar\Duration::class, 'DURATION' => Property\ICalendar\Duration::class,
'FREEBUSY' => VObject\Property\ICalendar\Period::class, 'FREEBUSY' => Property\ICalendar\Period::class,
'TRANSP' => VObject\Property\FlatText::class, 'TRANSP' => Property\FlatText::class,
// Time Zone Component Properties // Time Zone Component Properties
'TZID' => VObject\Property\FlatText::class, 'TZID' => Property\FlatText::class,
'TZNAME' => VObject\Property\FlatText::class, 'TZNAME' => Property\FlatText::class,
'TZOFFSETFROM' => VObject\Property\UtcOffset::class, 'TZOFFSETFROM' => Property\UtcOffset::class,
'TZOFFSETTO' => VObject\Property\UtcOffset::class, 'TZOFFSETTO' => Property\UtcOffset::class,
'TZURL' => VObject\Property\Uri::class, 'TZURL' => Property\Uri::class,
// Relationship Component Properties // Relationship Component Properties
'ATTENDEE' => VObject\Property\ICalendar\CalAddress::class, 'ATTENDEE' => Property\ICalendar\CalAddress::class,
'CONTACT' => VObject\Property\FlatText::class, 'CONTACT' => Property\FlatText::class,
'ORGANIZER' => VObject\Property\ICalendar\CalAddress::class, 'ORGANIZER' => Property\ICalendar\CalAddress::class,
'RECURRENCE-ID' => VObject\Property\ICalendar\DateTime::class, 'RECURRENCE-ID' => Property\ICalendar\DateTime::class,
'RELATED-TO' => VObject\Property\FlatText::class, 'RELATED-TO' => Property\FlatText::class,
'URL' => VObject\Property\Uri::class, 'URL' => Property\Uri::class,
'UID' => VObject\Property\FlatText::class, 'UID' => Property\FlatText::class,
// Recurrence Component Properties // Recurrence Component Properties
'EXDATE' => VObject\Property\ICalendar\DateTime::class, 'EXDATE' => Property\ICalendar\DateTime::class,
'RDATE' => VObject\Property\ICalendar\DateTime::class, 'RDATE' => Property\ICalendar\DateTime::class,
'RRULE' => VObject\Property\ICalendar\Recur::class, 'RRULE' => Property\ICalendar\Recur::class,
'EXRULE' => VObject\Property\ICalendar\Recur::class, // Deprecated since rfc5545 'EXRULE' => Property\ICalendar\Recur::class, // Deprecated since rfc5545
// Alarm Component Properties // Alarm Component Properties
'ACTION' => VObject\Property\FlatText::class, 'ACTION' => Property\FlatText::class,
'REPEAT' => VObject\Property\IntegerValue::class, 'REPEAT' => Property\IntegerValue::class,
'TRIGGER' => VObject\Property\ICalendar\Duration::class, 'TRIGGER' => Property\ICalendar\Duration::class,
// Change Management Component Properties // Change Management Component Properties
'CREATED' => VObject\Property\ICalendar\DateTime::class, 'CREATED' => Property\ICalendar\DateTime::class,
'DTSTAMP' => VObject\Property\ICalendar\DateTime::class, 'DTSTAMP' => Property\ICalendar\DateTime::class,
'LAST-MODIFIED' => VObject\Property\ICalendar\DateTime::class, 'LAST-MODIFIED' => Property\ICalendar\DateTime::class,
'SEQUENCE' => VObject\Property\IntegerValue::class, 'SEQUENCE' => Property\IntegerValue::class,
// Request Status // Request Status
'REQUEST-STATUS' => VObject\Property\Text::class, 'REQUEST-STATUS' => Property\Text::class,
// Additions from draft-daboo-valarm-extensions-04 // Additions from draft-daboo-valarm-extensions-04
'ALARM-AGENT' => VObject\Property\Text::class, 'ALARM-AGENT' => Property\Text::class,
'ACKNOWLEDGED' => VObject\Property\ICalendar\DateTime::class, 'ACKNOWLEDGED' => Property\ICalendar\DateTime::class,
'PROXIMITY' => VObject\Property\Text::class, 'PROXIMITY' => Property\Text::class,
'DEFAULT-ALARM' => VObject\Property\Boolean::class, 'DEFAULT-ALARM' => Property\Boolean::class,
// Additions from draft-daboo-calendar-availability-05 // Additions from draft-daboo-calendar-availability-05
'BUSYTYPE' => VObject\Property\Text::class, 'BUSYTYPE' => Property\Text::class,
// RFC 7986 properties
'NAME' => Property\FlatText::class,
'COLOR' => Property\FlatText::class,
]; ];
/** /**
@ -164,12 +168,12 @@ class VCalendar extends VObject\Document
* *
* @param string|null $componentName filter by component name * @param string|null $componentName filter by component name
* *
* @return VObject\Component[] * @return Component[]
*/ */
public function getBaseComponents(string $componentName = null): array public function getBaseComponents(?string $componentName = null): array
{ {
$isBaseComponent = function ($component): bool { $isBaseComponent = function ($component): bool {
if (!$component instanceof VObject\Component) { if (!$component instanceof Component) {
return false; return false;
} }
if ('VTIMEZONE' === $component->name) { if ('VTIMEZONE' === $component->name) {
@ -214,13 +218,11 @@ class VCalendar extends VObject\Document
* If there is no such component, null will be returned. * If there is no such component, null will be returned.
* *
* @param string|null $componentName filter by component name * @param string|null $componentName filter by component name
*
* @return VObject\Component|null
*/ */
public function getBaseComponent(string $componentName = null): ?Component public function getBaseComponent(?string $componentName = null): ?Component
{ {
$isBaseComponent = function ($component): bool { $isBaseComponent = function ($component): bool {
if (!$component instanceof VObject\Component) { if (!$component instanceof Component) {
return false; return false;
} }
if ('VTIMEZONE' === $component->name) { if ('VTIMEZONE' === $component->name) {
@ -275,7 +277,7 @@ class VCalendar extends VObject\Document
* @throws InvalidDataException * @throws InvalidDataException
* @throws VObject\Recur\MaxInstancesExceededException * @throws VObject\Recur\MaxInstancesExceededException
*/ */
public function expand(\DateTimeInterface $start, \DateTimeInterface $end, \DateTimeZone $timeZone = null): VCalendar public function expand(\DateTimeInterface $start, \DateTimeInterface $end, ?\DateTimeZone $timeZone = null): VCalendar
{ {
$newChildren = []; $newChildren = [];
$recurringEvents = []; $recurringEvents = [];
@ -329,7 +331,7 @@ class VCalendar extends VObject\Document
foreach ($recurringEvents as $events) { foreach ($recurringEvents as $events) {
try { try {
$it = new EventIterator($events, null, $timeZone); $it = new EventIterator($events, null, $timeZone);
} catch (NoInstancesException $e) { } catch (NoInstancesException) {
// This event is recurring, but it doesn't have a single // This event is recurring, but it doesn't have a single
// instance. We are skipping this event from the output // instance. We are skipping this event from the output
// entirely. // entirely.
@ -381,6 +383,18 @@ class VCalendar extends VObject\Document
'CALSCALE' => '?', 'CALSCALE' => '?',
'METHOD' => '?', 'METHOD' => '?',
// RFC 7986 calendar properties
'UID' => '?',
'LAST-MODIFIED' => '?',
'URL' => '?',
'REFRESH-INTERVAL' => '?',
'SOURCE' => '?',
'COLOR' => '?',
'NAME' => '*',
'DESCRIPTION' => '*',
'CATEGORIES' => '*',
'IMAGE' => '*',
]; ];
} }
@ -426,7 +440,7 @@ class VCalendar extends VObject\Document
if ($child instanceof Component) { if ($child instanceof Component) {
++$componentsFound; ++$componentsFound;
if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'])) { if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'], true)) {
continue; continue;
} }
$componentTypes[] = $child->name; $componentTypes[] = $child->name;

View file

@ -82,7 +82,7 @@ class VCard extends VObject\Document
'ROLE' => VObject\Property\FlatText::class, 'ROLE' => VObject\Property\FlatText::class,
'LOGO' => VObject\Property\Binary::class, 'LOGO' => VObject\Property\Binary::class,
// 'AGENT' => 'Sabre\\VObject\\Property\\', // Todo: is an embedded vCard. Probably rare, so // 'AGENT' => 'Sabre\\VObject\\Property\\', // Todo: is an embedded vCard. Probably rare, so
// not supported at the moment // not supported at the moment
'ORG' => VObject\Property\Text::class, 'ORG' => VObject\Property\Text::class,
'NOTE' => VObject\Property\FlatText::class, 'NOTE' => VObject\Property\FlatText::class,
'REV' => VObject\Property\VCard\TimeStamp::class, 'REV' => VObject\Property\VCard\TimeStamp::class,
@ -132,6 +132,13 @@ class VCard extends VObject\Document
'ORG-DIRECTORY' => VObject\Property\FlatText::class, 'ORG-DIRECTORY' => VObject\Property\FlatText::class,
'X-CRYPTO' => VObject\Property\XCrypto::class, 'X-CRYPTO' => VObject\Property\XCrypto::class,
// rfc9554 properties
'CREATED' => VObject\Property\VCard\TimeStamp::class,
'GRAMGENDER' => VObject\Property\FlatText::class,
'LANGUAGE' => VObject\Property\VCard\LanguageTag::class,
'PRONOUNS' => VObject\Property\FlatText::class,
'SOCIALPROFILE' => VObject\Property\Uri::class,
]; ];
/** /**
@ -326,6 +333,11 @@ class VCard extends VObject\Document
'CALURI' => '*', 'CALURI' => '*',
'CATEGORIES' => '*', 'CATEGORIES' => '*',
'CLIENTPIDMAP' => '*', 'CLIENTPIDMAP' => '*',
'CREATED' => '?',
'GRAMGENDER' => '*',
'LANGUAGE' => '?',
'PRONOUNS' => '*',
'SOCIALPROFILE' => '*',
'EMAIL' => '*', 'EMAIL' => '*',
'FBURL' => '*', 'FBURL' => '*',
'IMPP' => '*', 'IMPP' => '*',
@ -411,6 +423,34 @@ class VCard extends VObject\Document
return null; return null;
} }
/**
* Returns a property with a specific TYPE value (ADR, TEL, or EMAIL).
*
* This function will return null if the exact property list does not exist.
*
* For example to get the property of `TEL;TYPE=HOME,CELL`
* you would call `getByTypes('TEL', ['HOME', 'CELL'])`
*
* @param string[] $types
*
* @return \ArrayAccess|array|null
*/
public function getByTypes(string $propertyName, array $types)
{
$types = array_map(strtolower(...), $types);
foreach ($this->select($propertyName) as $field) {
if (isset($field['TYPE'])) {
$parts = array_map(strtolower(...), $field['TYPE']->getParts());
if (!array_diff($types, $parts) && !array_diff($parts, $types)) {
return $field;
}
}
}
return null;
}
/** /**
* This method should return a list of default property values. * This method should return a list of default property values.
*/ */
@ -453,11 +493,9 @@ class VCard extends VObject\Document
$propertiesByGroup = []; $propertiesByGroup = [];
foreach ($this->children() as $property) { foreach ($this->children() as $property) {
$group = $property->group; $group = $property->group ?? '';
if (!isset($propertiesByGroup[$group])) { $propertiesByGroup[$group] ??= [];
$propertiesByGroup[$group] = [];
}
$propertiesByGroup[$group][] = $property; $propertiesByGroup[$group][] = $property;
} }
@ -465,9 +503,9 @@ class VCard extends VObject\Document
$writer->startElement(strtolower($this->name)); $writer->startElement(strtolower($this->name));
foreach ($propertiesByGroup as $group => $properties) { foreach ($propertiesByGroup as $group => $properties) {
if (!empty($group)) { if ('' !== $group) {
$writer->startElement('group'); $writer->startElement('group');
$writer->writeAttribute('name', strtolower($group)); $writer->writeAttribute('name', strtolower((string) $group));
} }
foreach ($properties as $property) { foreach ($properties as $property) {
@ -487,7 +525,7 @@ class VCard extends VObject\Document
} }
} }
if (!empty($group)) { if ('' !== $group) {
$writer->endElement(); $writer->endElement();
} }
} }
@ -503,7 +541,7 @@ class VCard extends VObject\Document
$className = parent::getClassNameForPropertyName($propertyName); $className = parent::getClassNameForPropertyName($propertyName);
// In vCard 4, BINARY no longer exists, and we need URI instead. // In vCard 4, BINARY no longer exists, and we need URI instead.
if (VObject\Property\Binary::class == $className && self::VCARD40 === $this->getDocumentType()) { if (VObject\Property\Binary::class === $className && self::VCARD40 === $this->getDocumentType()) {
return VObject\Property\Uri::class; return VObject\Property\Uri::class;
} }

View file

@ -41,10 +41,10 @@ class VEvent extends VObject\Component
*/ */
public function isInTimeRange(\DateTimeInterface $start, \DateTimeInterface $end): bool public function isInTimeRange(\DateTimeInterface $start, \DateTimeInterface $end): bool
{ {
if ($this->RRULE) { if ($this->RRULE || $this->RDATE) {
try { try {
$it = new EventIterator($this, null, $start->getTimezone()); $it = new EventIterator($this, null, $start->getTimezone());
} catch (NoInstancesException $e) { } catch (NoInstancesException) {
// If we've caught this exception, there are no instances // If we've caught this exception, there are no instances
// for the event that fall into the specified time-range. // for the event that fall into the specified time-range.
return false; return false;
@ -117,6 +117,7 @@ class VEvent extends VObject\Component
'DTSTART' => $hasMethod ? '?' : '1', 'DTSTART' => $hasMethod ? '?' : '1',
'CLASS' => '?', 'CLASS' => '?',
'CREATED' => '?', 'CREATED' => '?',
'COLOR' => '?',
'DESCRIPTION' => '?', 'DESCRIPTION' => '?',
'GEO' => '?', 'GEO' => '?',
'LAST-MODIFIED' => '?', 'LAST-MODIFIED' => '?',
@ -137,7 +138,9 @@ class VEvent extends VObject\Component
'ATTENDEE' => '*', 'ATTENDEE' => '*',
'CATEGORIES' => '*', 'CATEGORIES' => '*',
'COMMENT' => '*', 'COMMENT' => '*',
'CONFERENCE' => '*',
'CONTACT' => '*', 'CONTACT' => '*',
'IMAGE' => '*',
'EXDATE' => '*', 'EXDATE' => '*',
'REQUEST-STATUS' => '*', 'REQUEST-STATUS' => '*',
'RELATED-TO' => '*', 'RELATED-TO' => '*',

View file

@ -41,7 +41,7 @@ class VFreeBusy extends VObject\Component
// Every period is formatted as [start]/[end]. The start is an // Every period is formatted as [start]/[end]. The start is an
// absolute UTC time, the end may be an absolute UTC time, or // absolute UTC time, the end may be an absolute UTC time, or
// duration (relative) value. // duration (relative) value.
list($busyStart, $busyEnd) = explode('/', $period); [$busyStart, $busyEnd] = explode('/', $period);
$busyStart = VObject\DateTimeParser::parse($busyStart); $busyStart = VObject\DateTimeParser::parse($busyStart);
$busyEnd = VObject\DateTimeParser::parse($busyEnd); $busyEnd = VObject\DateTimeParser::parse($busyEnd);

View file

@ -62,6 +62,7 @@ class VJournal extends VObject\Component
'CLASS' => '?', 'CLASS' => '?',
'CREATED' => '?', 'CREATED' => '?',
'COLOR' => '?',
'DTSTART' => '?', 'DTSTART' => '?',
'LAST-MODIFIED' => '?', 'LAST-MODIFIED' => '?',
'ORGANIZER' => '?', 'ORGANIZER' => '?',
@ -78,6 +79,7 @@ class VJournal extends VObject\Component
'CATEGORIES' => '*', 'CATEGORIES' => '*',
'COMMENT' => '*', 'COMMENT' => '*',
'CONTACT' => '*', 'CONTACT' => '*',
'IMAGE' => '*',
'DESCRIPTION' => '*', 'DESCRIPTION' => '*',
'EXDATE' => '*', 'EXDATE' => '*',
'RELATED-TO' => '*', 'RELATED-TO' => '*',

View file

@ -54,9 +54,9 @@ class VTodo extends VObject\Component
return return
($start < $due || $start <= $dtstart) ($start < $due || $start <= $dtstart)
&& ($end > $dtstart || $end >= $due); && ($end > $dtstart || $end >= $due);
} else {
return $start <= $dtstart && $end > $dtstart;
} }
return $start <= $dtstart && $end > $dtstart;
} }
if ($due) { if ($due) {
return $start < $due && $end >= $due; return $start < $due && $end >= $due;
@ -98,13 +98,14 @@ class VTodo extends VObject\Component
'CLASS' => '?', 'CLASS' => '?',
'COMPLETED' => '?', 'COMPLETED' => '?',
'CREATED' => '?', 'CREATED' => '?',
'COLOR' => '?',
'DESCRIPTION' => '?', 'DESCRIPTION' => '?',
'DTSTART' => '?', 'DTSTART' => '?',
'GEO' => '?', 'GEO' => '?',
'LAST-MODIFIED' => '?', 'LAST-MODIFIED' => '?',
'LOCATION' => '?', 'LOCATION' => '?',
'ORGANIZER' => '?', 'ORGANIZER' => '?',
'PERCENT' => '?', 'PERCENT-COMPLETE' => '?',
'PRIORITY' => '?', 'PRIORITY' => '?',
'RECURRENCE-ID' => '?', 'RECURRENCE-ID' => '?',
'SEQUENCE' => '?', 'SEQUENCE' => '?',
@ -120,7 +121,9 @@ class VTodo extends VObject\Component
'ATTENDEE' => '*', 'ATTENDEE' => '*',
'CATEGORIES' => '*', 'CATEGORIES' => '*',
'COMMENT' => '*', 'COMMENT' => '*',
'CONFERENCE' => '*',
'CONTACT' => '*', 'CONTACT' => '*',
'IMAGE' => '*',
'EXDATE' => '*', 'EXDATE' => '*',
'REQUEST-STATUS' => '*', 'REQUEST-STATUS' => '*',
'RELATED-TO' => '*', 'RELATED-TO' => '*',

View file

@ -27,7 +27,7 @@ class DateTimeParser
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public static function parseDateTime(string $dt, \DateTimeZone $tz = null): \DateTimeImmutable public static function parseDateTime(string $dt, ?\DateTimeZone $tz = null): \DateTimeImmutable
{ {
// Format is YYYYMMDD + "T" + hhmmss // Format is YYYYMMDD + "T" + hhmmss
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9])([0-5][0-9])([0-5][0-9])([Z]?)$/', $dt, $matches); $result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9])([0-5][0-9])([0-5][0-9])([Z]?)$/', $dt, $matches);
@ -42,7 +42,7 @@ class DateTimeParser
try { try {
$date = new \DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3].' '.$matches[4].':'.$matches[5].':'.$matches[6], $tz); $date = new \DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3].' '.$matches[4].':'.$matches[5].':'.$matches[6], $tz);
} catch (\Exception $e) { } catch (\Exception) {
throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: '.$dt); throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: '.$dt);
} }
@ -54,7 +54,7 @@ class DateTimeParser
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public static function parseDate(string $date, \DateTimeZone $tz = null): \DateTimeImmutable public static function parseDate(string $date, ?\DateTimeZone $tz = null): \DateTimeImmutable
{ {
// Format is YYYYMMDD // Format is YYYYMMDD
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches); $result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches);
@ -63,13 +63,11 @@ class DateTimeParser
throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date); throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
} }
if (is_null($tz)) { $tz ??= new \DateTimeZone('UTC');
$tz = new \DateTimeZone('UTC');
}
try { try {
$date = new \DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3], $tz); $date = new \DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3], $tz);
} catch (\Exception $e) { } catch (\Exception) {
throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date); throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
} }
@ -98,12 +96,12 @@ class DateTimeParser
} }
$parts = [ $parts = [
'week', 'week',
'day', 'day',
'hour', 'hour',
'minute', 'minute',
'second', 'second',
]; ];
foreach ($parts as $part) { foreach ($parts as $part) {
$matches[$part] = isset($matches[$part]) && $matches[$part] ? (int) $matches[$part] : 0; $matches[$part] = isset($matches[$part]) && $matches[$part] ? (int) $matches[$part] : 0;
@ -205,9 +203,9 @@ class DateTimeParser
return self::parseDuration($date); return self::parseDuration($date);
} elseif (8 === strlen($date)) { } elseif (8 === strlen($date)) {
return self::parseDate($date, $referenceTz); return self::parseDate($date, $referenceTz);
} else {
return self::parseDateTime($date, $referenceTz);
} }
return self::parseDateTime($date, $referenceTz);
} }
/** /**
@ -329,7 +327,7 @@ class DateTimeParser
$result = []; $result = [];
foreach ($parts as $part) { foreach ($parts as $part) {
if (empty($matches[$part])) { if (!array_key_exists($part, $matches) || '' === $matches[$part]) {
$result[$part] = null; $result[$part] = null;
} elseif ('-' === $matches[$part] || '--' === $matches[$part]) { } elseif ('-' === $matches[$part] || '--' === $matches[$part]) {
$result[$part] = null; $result[$part] = null;
@ -424,7 +422,7 @@ class DateTimeParser
$result = []; $result = [];
foreach ($parts as $part) { foreach ($parts as $part) {
if (empty($matches[$part])) { if (!array_key_exists($part, $matches) || '' === $matches[$part]) {
$result[$part] = null; $result[$part] = null;
} elseif ('-' === $matches[$part]) { } elseif ('-' === $matches[$part]) {
$result[$part] = null; $result[$part] = null;
@ -541,7 +539,7 @@ class DateTimeParser
$parts['year0'] = &$parts['year']; $parts['year0'] = &$parts['year'];
foreach ($parts as $part => &$value) { foreach ($parts as $part => &$value) {
if (!empty($matches[$part])) { if (array_key_exists($part, $matches) && '' !== $matches[$part]) {
$value = $matches[$part]; $value = $matches[$part];
} }
} }

View file

@ -2,8 +2,6 @@
namespace Sabre\VObject; namespace Sabre\VObject;
use Sabre\VObject;
/** /**
* Document. * Document.
* *
@ -18,7 +16,7 @@ use Sabre\VObject;
* @author Evert Pot (http://evertpot.com/) * @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License * @license http://sabre.io/license/ Modified BSD License
* *
* @property VObject\Property\FlatText VERSION * @property Property\FlatText VERSION
*/ */
abstract class Document extends Component abstract class Document extends Component
{ {
@ -120,10 +118,10 @@ abstract class Document extends Component
public function create(string $name) public function create(string $name)
{ {
if (isset(static::$componentMap[strtoupper($name)])) { if (isset(static::$componentMap[strtoupper($name)])) {
return call_user_func_array([$this, 'createComponent'], func_get_args()); return $this->createComponent(...func_get_args());
} else {
return call_user_func_array([$this, 'createProperty'], func_get_args());
} }
return $this->createProperty(...func_get_args());
} }
/** /**
@ -140,7 +138,7 @@ abstract class Document extends Component
* an iCalendar object, this may be something like CALSCALE:GREGORIAN. To * an iCalendar object, this may be something like CALSCALE:GREGORIAN. To
* ensure that this does not happen, set $defaults to false. * ensure that this does not happen, set $defaults to false.
*/ */
public function createComponent(string $name, array $children = null, bool $defaults = true): Component public function createComponent(string $name, ?array $children = null, bool $defaults = true): Component
{ {
$name = strtoupper($name); $name = strtoupper($name);
$class = Component::class; $class = Component::class;
@ -148,9 +146,7 @@ abstract class Document extends Component
if (isset(static::$componentMap[$name])) { if (isset(static::$componentMap[$name])) {
$class = static::$componentMap[$name]; $class = static::$componentMap[$name];
} }
if (is_null($children)) { $children ??= [];
$children = [];
}
return new $class($this, $name, $children, $defaults); return new $class($this, $name, $children, $defaults);
} }
@ -169,7 +165,7 @@ abstract class Document extends Component
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public function createProperty(string $name, $value = null, array $parameters = null, string $valueType = null): Property public function createProperty(string $name, $value = null, ?array $parameters = null, ?string $valueType = null, ?int $lineIndex = null, ?string $lineString = null): Property
{ {
// If there's a . in the name, it means it's prefixed by a group name. // If there's a . in the name, it means it's prefixed by a group name.
if (false !== ($i = strpos($name, '.'))) { if (false !== ($i = strpos($name, '.'))) {
@ -182,28 +178,31 @@ abstract class Document extends Component
$class = null; $class = null;
// If a VALUE parameter is supplied, we have to use that
// According to https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.20
// If the property's value is the default value type, then this
// parameter need not be specified. However, if the property's
// default value type is overridden by some other allowable value
// type, then this parameter MUST be specified.
if (!$valueType) {
$valueType = $parameters['VALUE'] ?? null;
}
if ($valueType) { if ($valueType) {
// The valueType argument comes first to figure out the correct // The valueType argument comes first to figure out the correct
// class. // class.
$class = $this->getClassNameForPropertyValue($valueType); $class = $this->getClassNameForPropertyValue($valueType);
} }
if (is_null($class)) { // If the value parameter is not set or set to something we do not recognize
// If a VALUE parameter is supplied, we should use that. // we do not attempt to interpret or parse the datass value as specified in
if (isset($parameters['VALUE'])) { // https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.20
$class = $this->getClassNameForPropertyValue($parameters['VALUE']); // So when we so far did not get a class-name, we use the default for the property
if (is_null($class)) { $class ??= $this->getClassNameForPropertyName($name);
throw new InvalidDataException('Unsupported VALUE parameter for '.$name.' property. You supplied "'.$parameters['VALUE'].'"');
}
} else {
$class = $this->getClassNameForPropertyName($name);
}
}
if (is_null($parameters)) {
$parameters = [];
}
return new $class($this, $name, $value, $parameters, $group); $parameters ??= [];
return new $class($this, $name, $value, $parameters, $group, $lineIndex, $lineString);
} }
/** /**

View file

@ -11,25 +11,19 @@ namespace Sabre\VObject;
*/ */
class FreeBusyData class FreeBusyData
{ {
/**
* Start timestamp.
*/
protected int $start;
/**
* End timestamp.
*/
protected int $end;
/** /**
* A list of free-busy times. * A list of free-busy times.
*/ */
protected array $data; protected array $data;
public function __construct(int $start, int $end) public function __construct(/**
* Start timestamp.
*/
protected int $start, /**
* End timestamp.
*/
protected int $end)
{ {
$this->start = $start;
$this->end = $end;
$this->data = []; $this->data = [];
$this->data[] = [ $this->data[] = [

View file

@ -72,16 +72,14 @@ class FreeBusyGenerator
* Check the setTimeRange and setObjects methods for details about the * Check the setTimeRange and setObjects methods for details about the
* arguments. * arguments.
*/ */
public function __construct(\DateTimeInterface $start = null, \DateTimeInterface $end = null, $objects = null, \DateTimeZone $timeZone = null) public function __construct(?\DateTimeInterface $start = null, ?\DateTimeInterface $end = null, $objects = null, ?\DateTimeZone $timeZone = null)
{ {
$this->setTimeRange($start, $end); $this->setTimeRange($start, $end);
if ($objects) { if ($objects) {
$this->setObjects($objects); $this->setObjects($objects);
} }
if (is_null($timeZone)) { $timeZone ??= new \DateTimeZone('UTC');
$timeZone = new \DateTimeZone('UTC');
}
$this->setTimeZone($timeZone); $this->setTimeZone($timeZone);
} }
@ -138,7 +136,7 @@ class FreeBusyGenerator
* *
* @throws \Exception * @throws \Exception
*/ */
public function setTimeRange(\DateTimeInterface $start = null, \DateTimeInterface $end = null): void public function setTimeRange(?\DateTimeInterface $start = null, ?\DateTimeInterface $end = null): void
{ {
if (!$start) { if (!$start) {
$start = new \DateTimeImmutable(Settings::$minDate); $start = new \DateTimeImmutable(Settings::$minDate);
@ -217,7 +215,7 @@ class FreeBusyGenerator
$new = []; $new = [];
foreach ($old as $vavail) { foreach ($old as $vavail) {
list($compStart, $compEnd) = $vavail->getEffectiveStartEnd(); [$compStart, $compEnd] = $vavail->getEffectiveStartEnd();
// We don't care about date-times that are earlier or later than the // We don't care about date-times that are earlier or later than the
// start and end of the freebusy report, so this gets normalized // start and end of the freebusy report, so this gets normalized
@ -237,7 +235,7 @@ class FreeBusyGenerator
// Going through our existing list of components to see if there's // Going through our existing list of components to see if there's
// a higher priority component that already fully covers this one. // a higher priority component that already fully covers this one.
foreach ($new as $higherVavail) { foreach ($new as $higherVavail) {
list($higherStart, $higherEnd) = $higherVavail->getEffectiveStartEnd(); [$higherStart, $higherEnd] = $higherVavail->getEffectiveStartEnd();
if ( if (
(is_null($higherStart) || $higherStart < $compStart) (is_null($higherStart) || $higherStart < $compStart)
&& (is_null($higherEnd) || $higherEnd > $compEnd) && (is_null($higherEnd) || $higherEnd > $compEnd)
@ -259,7 +257,7 @@ class FreeBusyGenerator
// priority components to override the lower ones. // priority components to override the lower ones.
foreach (array_reverse($new) as $vavail) { foreach (array_reverse($new) as $vavail) {
$busyType = isset($vavail->BUSYTYPE) ? strtoupper($vavail->BUSYTYPE) : 'BUSY-UNAVAILABLE'; $busyType = isset($vavail->BUSYTYPE) ? strtoupper($vavail->BUSYTYPE) : 'BUSY-UNAVAILABLE';
list($vavailStart, $vavailEnd) = $vavail->getEffectiveStartEnd(); [$vavailStart, $vavailEnd] = $vavail->getEffectiveStartEnd();
// Making the component size no larger than the requested free-busy // Making the component size no larger than the requested free-busy
// report range. // report range.
@ -281,7 +279,7 @@ class FreeBusyGenerator
// Looping over the AVAILABLE components. // Looping over the AVAILABLE components.
if (isset($vavail->AVAILABLE)) { if (isset($vavail->AVAILABLE)) {
foreach ($vavail->AVAILABLE as $available) { foreach ($vavail->AVAILABLE as $available) {
list($availStart, $availEnd) = $available->getEffectiveStartEnd(); [$availStart, $availEnd] = $available->getEffectiveStartEnd();
$fbData->add( $fbData->add(
$availStart->getTimeStamp(), $availStart->getTimeStamp(),
$availEnd->getTimeStamp(), $availEnd->getTimeStamp(),
@ -362,7 +360,7 @@ class FreeBusyGenerator
if ($component->RRULE) { if ($component->RRULE) {
try { try {
$iterator = new EventIterator($object, (string) $component->UID, $this->timeZone); $iterator = new EventIterator($object, (string) $component->UID, $this->timeZone);
} catch (NoInstancesException $e) { } catch (NoInstancesException) {
// This event is recurring, but it doesn't have a single // This event is recurring, but it doesn't have a single
// instance. We are skipping this event from the output // instance. We are skipping this event from the output
// entirely. // entirely.
@ -437,12 +435,12 @@ class FreeBusyGenerator
continue; continue;
} }
$values = explode(',', $freebusy); $values = explode(',', (string) $freebusy);
foreach ($values as $value) { foreach ($values as $value) {
list($startTime, $endTime) = explode('/', $value); [$startTime, $endTime] = explode('/', $value);
$startTime = DateTimeParser::parseDateTime($startTime); $startTime = DateTimeParser::parseDateTime($startTime);
if ('P' === substr($endTime, 0, 1) || '-P' === substr($endTime, 0, 2)) { if (str_starts_with($endTime, 'P') || str_starts_with($endTime, '-P')) {
$duration = DateTimeParser::parseDuration($endTime); $duration = DateTimeParser::parseDuration($endTime);
$endTime = clone $startTime; $endTime = clone $startTime;
$endTime = $endTime->add($duration); $endTime = $endTime->add($duration);
@ -507,7 +505,7 @@ class FreeBusyGenerator
$vfreebusy->add($dtstamp); $vfreebusy->add($dtstamp);
foreach ($fbData->getData() as $busyTime) { foreach ($fbData->getData() as $busyTime) {
$busyType = strtoupper($busyTime['type']); $busyType = strtoupper((string) $busyTime['type']);
// Ignoring all the FREE parts, because those are already assumed. // Ignoring all the FREE parts, because those are already assumed.
if ('FREE' === $busyType) { if ('FREE' === $busyType) {

View file

@ -113,7 +113,7 @@ class Broker
* @throws MaxInstancesExceededException * @throws MaxInstancesExceededException
* @throws NoInstancesException * @throws NoInstancesException
*/ */
public function processMessage(Message $itipMessage, VCalendar $existingObject = null) public function processMessage(Message $itipMessage, ?VCalendar $existingObject = null)
{ {
// We only support events at the moment. // We only support events at the moment.
if ('VEVENT' !== $itipMessage->component) { if ('VEVENT' !== $itipMessage->component) {
@ -228,7 +228,7 @@ class Broker
$eventInfo = $oldEventInfo; $eventInfo = $oldEventInfo;
if (in_array($eventInfo['organizer'], $userHref)) { if (in_array($eventInfo['organizer'], $userHref, true)) {
// This is an organizer deleting the event. // This is an organizer deleting the event.
$eventInfo['attendees'] = []; $eventInfo['attendees'] = [];
// Increasing the sequence, but only if the organizer deleted // Increasing the sequence, but only if the organizer deleted
@ -237,7 +237,7 @@ class Broker
} else { } else {
// This is an attendee deleting the event. // This is an attendee deleting the event.
foreach ($eventInfo['attendees'] as $key => $attendee) { foreach ($eventInfo['attendees'] as $key => $attendee) {
if (in_array($attendee['href'], $userHref)) { if (in_array($attendee['href'], $userHref, true)) {
$eventInfo['attendees'][$key]['instances'] = ['master' => ['id' => 'master', 'partstat' => 'DECLINED'], $eventInfo['attendees'][$key]['instances'] = ['master' => ['id' => 'master', 'partstat' => 'DECLINED'],
]; ];
} }
@ -246,16 +246,29 @@ class Broker
$baseCalendar = $oldCalendar; $baseCalendar = $oldCalendar;
} }
if (in_array($eventInfo['organizer'], $userHref)) { // Check if the user is the organizer
if (in_array($eventInfo['organizer'], $userHref, true)) {
return $this->parseEventForOrganizer($baseCalendar, $eventInfo, $oldEventInfo); return $this->parseEventForOrganizer($baseCalendar, $eventInfo, $oldEventInfo);
} elseif ($oldCalendar) { }
// We need to figure out if the user is an attendee, but we're only
// doing so if there's an oldCalendar, because we only want to // Check if the user is an attendee
// process updates, not creation of new events. foreach ($eventInfo['attendees'] as $attendee) {
foreach ($eventInfo['attendees'] as $attendee) { if (in_array($attendee['href'], $userHref, true)) {
if (in_array($attendee['href'], $userHref)) { // If this is a event update, we always generate a reply
if ($oldCalendar) {
return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']); return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']);
} }
// If this is a new event, we only generate a reply if the participation status is set
foreach ($attendee['instances'] as $instance) {
if (isset($instance['partstat']) && 'NEEDS-ACTION' !== $instance['partstat']) {
// Attendee has responded (ACCEPTED/DECLINED/TENTATIVE) - generate REPLY
return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']);
}
}
// User is attendee but no response to process
break;
} }
} }
@ -268,7 +281,7 @@ class Broker
* This is message from an organizer, and is either a new event * This is message from an organizer, and is either a new event
* invite, or an update to an existing one. * invite, or an update to an existing one.
*/ */
protected function processMessageRequest(Message $itipMessage, VCalendar $existingObject = null): ?VCalendar protected function processMessageRequest(Message $itipMessage, ?VCalendar $existingObject = null): ?VCalendar
{ {
if (!$existingObject) { if (!$existingObject) {
// This is a new invite, and we're just going to copy over // This is a new invite, and we're just going to copy over
@ -296,7 +309,7 @@ class Broker
* attendee got removed from an event, or an event got cancelled * attendee got removed from an event, or an event got cancelled
* altogether. * altogether.
*/ */
protected function processMessageCancel(Message $itipMessage, VCalendar $existingObject = null): ?VCalendar protected function processMessageCancel(Message $itipMessage, ?VCalendar $existingObject = null): ?VCalendar
{ {
if (!$existingObject) { if (!$existingObject) {
// The event didn't exist in the first place, so we're just // The event didn't exist in the first place, so we're just
@ -321,7 +334,7 @@ class Broker
* @throws MaxInstancesExceededException * @throws MaxInstancesExceededException
* @throws NoInstancesException * @throws NoInstancesException
*/ */
protected function processMessageReply(Message $itipMessage, VCalendar $existingObject = null): ?VCalendar protected function processMessageReply(Message $itipMessage, ?VCalendar $existingObject = null): ?VCalendar
{ {
// A reply can only be processed based on an existing object. // A reply can only be processed based on an existing object.
// If the object is not available, the reply is ignored. // If the object is not available, the reply is ignored.
@ -333,12 +346,15 @@ class Broker
// Finding all the instances the attendee replied to. // Finding all the instances the attendee replied to.
foreach ($itipMessage->message->VEVENT as $vevent) { foreach ($itipMessage->message->VEVENT as $vevent) {
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $vevent->ATTENDEE; $attendee = $vevent->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue(); $instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($vevent->{'REQUEST-STATUS'})) { if (isset($vevent->{'REQUEST-STATUS'})) {
$requestStatus = $vevent->{'REQUEST-STATUS'}->getValue(); $requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
list($requestStatus) = explode(';', $requestStatus); [$requestStatus] = explode(';', $requestStatus);
} }
} }
@ -346,7 +362,8 @@ class Broker
// all the instances where we have a reply for. // all the instances where we have a reply for.
$masterObject = null; $masterObject = null;
foreach ($existingObject->VEVENT as $vevent) { foreach ($existingObject->VEVENT as $vevent) {
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
if ('master' === $recurId) { if ('master' === $recurId) {
$masterObject = $vevent; $masterObject = $vevent;
} }
@ -393,7 +410,10 @@ class Broker
$newObject = $recurrenceIterator->getEventObject(); $newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next(); $recurrenceIterator->next();
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getValue() === $recurId) { // Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true; $found = true;
} }
--$iterations; --$iterations;
@ -472,7 +492,6 @@ class Broker
} }
$messages = []; $messages = [];
foreach ($attendees as $attendee) { foreach ($attendees as $attendee) {
// An organizer can also be an attendee. We should not generate any // An organizer can also be an attendee. We should not generate any
// messages for those. // messages for those.
@ -496,10 +515,11 @@ class Broker
$icalMsg->add(clone $timezone); $icalMsg->add(clone $timezone);
} }
if (!$attendee['newInstances']) { if (!$attendee['newInstances'] || 'CANCELLED' === $eventInfo['status']) {
// If there are no instances the attendee is a part of, it // If there are no instances the attendee is a part of, it means
// means the attendee was removed and we need to send him a // the attendee was removed and we need to send them a CANCEL message.
// CANCEL. // Also If the meeting STATUS property was changed to CANCELLED
// we need to send the attendee a CANCEL message.
$message->method = 'CANCEL'; $message->method = 'CANCEL';
$icalMsg->METHOD = $message->method; $icalMsg->METHOD = $message->method;
@ -546,7 +566,7 @@ class Broker
$message->significantChange = $message->significantChange =
'REQUEST' === $attendee['forceSend'] 'REQUEST' === $attendee['forceSend']
|| count($oldAttendeeInstances) != count($newAttendeeInstances) || count($oldAttendeeInstances) !== count($newAttendeeInstances)
|| count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 || count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0
|| $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; || $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash'];
@ -556,9 +576,9 @@ class Broker
// We need to find a list of events that the attendee // We need to find a list of events that the attendee
// is not a part of to add to the list of exceptions. // is not a part of to add to the list of exceptions.
$exceptions = []; $exceptions = [];
foreach ($eventInfo['instances'] as $instanceId => $vevent) { foreach ($eventInfo['instances'] as $eventInstanceId => $vevent) {
if (!isset($attendee['newInstances'][$instanceId])) { if (!isset($attendee['newInstances'][$eventInstanceId])) {
$exceptions[] = $instanceId; $exceptions[] = $eventInstanceId;
} }
} }
@ -572,6 +592,9 @@ class Broker
)); ));
} else { } else {
$currentEvent->EXDATE = $exceptions; $currentEvent->EXDATE = $exceptions;
if ($currentEvent->DTSTART['TZID']) {
$currentEvent->EXDATE['TZID'] = clone $currentEvent->DTSTART['TZID'];
}
} }
} }
@ -580,15 +603,13 @@ class Broker
unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']); unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']);
unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']); unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']);
foreach ($currentEvent->ATTENDEE as $attendee) { foreach ($currentEvent->ATTENDEE as $currentEventAttendee) {
unset($attendee['SCHEDULE-FORCE-SEND']); unset($currentEventAttendee['SCHEDULE-FORCE-SEND']);
unset($attendee['SCHEDULE-STATUS']); unset($currentEventAttendee['SCHEDULE-STATUS']);
// We're adding PARTSTAT=NEEDS-ACTION to ensure that // We're adding PARTSTAT=NEEDS-ACTION to ensure that
// iOS shows an "Inbox Item" // iOS shows an "Inbox Item"
if (!isset($attendee['PARTSTAT'])) { $currentEventAttendee['PARTSTAT'] ??= 'NEEDS-ACTION';
$attendee['PARTSTAT'] = 'NEEDS-ACTION';
}
} }
} }
@ -625,7 +646,12 @@ class Broker
return []; return [];
} }
$oldInstances = !empty($oldEventInfo['attendees'][$attendee]['instances']) ? $oldInstances =
is_array($oldEventInfo['attendees'])
&& array_key_exists($attendee, $oldEventInfo['attendees'])
&& is_array($oldEventInfo['attendees'][$attendee])
&& array_key_exists('instances', $oldEventInfo['attendees'][$attendee])
&& is_array($oldEventInfo['attendees'][$attendee]['instances']) ?
$oldEventInfo['attendees'][$attendee]['instances'] : $oldEventInfo['attendees'][$attendee]['instances'] :
[]; [];
@ -656,7 +682,7 @@ class Broker
// We only need to do that though, if the master event is not declined. // We only need to do that though, if the master event is not declined.
if (isset($instances['master']) && 'DECLINED' !== $instances['master']['newstatus']) { if (isset($instances['master']) && 'DECLINED' !== $instances['master']['newstatus']) {
foreach ($eventInfo['exdate'] as $exDate) { foreach ($eventInfo['exdate'] as $exDate) {
if (!in_array($exDate, $oldEventInfo['exdate'])) { if (!in_array($exDate, $oldEventInfo['exdate'] ?? [], true)) {
if (isset($instances[$exDate])) { if (isset($instances[$exDate])) {
$instances[$exDate]['newstatus'] = 'DECLINED'; $instances[$exDate]['newstatus'] = 'DECLINED';
} else { } else {
@ -699,7 +725,7 @@ class Broker
$hasReply = false; $hasReply = false;
foreach ($instances as $instance) { foreach ($instances as $instance) {
if ($instance['oldstatus'] == $instance['newstatus'] && 'REPLY' !== $eventInfo['organizerForceSend']) { if ($instance['oldstatus'] === $instance['newstatus'] && 'REPLY' !== $eventInfo['organizerForceSend']) {
// Skip // Skip
continue; continue;
} }
@ -731,7 +757,7 @@ class Broker
// EXDATE // EXDATE
$dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']); $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
// Treat is as a DATE field // Treat is as a DATE field
if (strlen($instance['id']) <= 8) { if (strlen((string) $instance['id']) <= 8) {
$event->add('DTSTART', $dt, ['VALUE' => 'DATE']); $event->add('DTSTART', $dt, ['VALUE' => 'DATE']);
} else { } else {
$event->add('DTSTART', $dt); $event->add('DTSTART', $dt);
@ -743,7 +769,7 @@ class Broker
if ('master' !== $instance['id']) { if ('master' !== $instance['id']) {
$dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']); $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
// Treat is as a DATE field // Treat is as a DATE field
if (strlen($instance['id']) <= 8) { if (strlen((string) $instance['id']) <= 8) {
$event->add('RECURRENCE-ID', $dt, ['VALUE' => 'DATE']); $event->add('RECURRENCE-ID', $dt, ['VALUE' => 'DATE']);
} else { } else {
$event->add('RECURRENCE-ID', $dt); $event->add('RECURRENCE-ID', $dt);
@ -766,9 +792,9 @@ class Broker
$message->message = $icalMsg; $message->message = $icalMsg;
return [$message]; return [$message];
} else {
return [];
} }
return [];
} }
/** /**
@ -835,7 +861,7 @@ class Broker
$organizer = $vevent->ORGANIZER->getNormalizedValue(); $organizer = $vevent->ORGANIZER->getNormalizedValue();
$organizerName = $vevent->ORGANIZER['CN'] ?? null; $organizerName = $vevent->ORGANIZER['CN'] ?? null;
} else { } else {
if (strtoupper($organizer) !== strtoupper($vevent->ORGANIZER->getNormalizedValue())) { if (strtoupper($organizer) !== strtoupper((string) $vevent->ORGANIZER->getNormalizedValue())) {
throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.'); throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.');
} }
} }
@ -861,7 +887,7 @@ class Broker
foreach ($vevent->select('RRULE') as $rr) { foreach ($vevent->select('RRULE') as $rr) {
foreach ($rr->getParts() as $key => $val) { foreach ($rr->getParts() as $key => $val) {
// ignore default values (https://github.com/sabre-io/vobject/issues/126) // ignore default values (https://github.com/sabre-io/vobject/issues/126)
if ('INTERVAL' === $key && 1 == $val) { if ('INTERVAL' === $key && '1' === (string) $val) {
continue; continue;
} }
if (is_array($val)) { if (is_array($val)) {
@ -873,7 +899,7 @@ class Broker
sort($rrule); sort($rrule);
} }
if (isset($vevent->STATUS)) { if (isset($vevent->STATUS)) {
$status = strtoupper($vevent->STATUS->getValue()); $status = strtoupper((string) $vevent->STATUS->getValue());
} }
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master';
@ -884,11 +910,14 @@ class Broker
$timezone = $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimeZone(); $timezone = $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimeZone();
} }
} }
$instances[$recurId] = $vevent;
if (isset($vevent->ATTENDEE)) { if (isset($vevent->ATTENDEE)) {
foreach ($vevent->ATTENDEE as $attendee) { foreach ($vevent->ATTENDEE as $attendee) {
if ($this->scheduleAgentServerRules if ($this->scheduleAgentServerRules
&& isset($attendee['SCHEDULE-AGENT']) && isset($attendee['SCHEDULE-AGENT'])
&& 'CLIENT' === strtoupper($attendee['SCHEDULE-AGENT']->getValue()) && 'CLIENT' === strtoupper((string) $attendee['SCHEDULE-AGENT']->getValue())
) { ) {
continue; continue;
} }
@ -922,10 +951,10 @@ class Broker
]; ];
} }
} }
$instances[$recurId] = $vevent;
} }
foreach ($this->significantChangeProperties as $prop) { foreach ($this->significantChangeProperties as $prop) {
// @phpstan-ignore property.dynamicName
if (isset($vevent->$prop)) { if (isset($vevent->$prop)) {
$propertyValues = $vevent->select($prop); $propertyValues = $vevent->select($prop);

View file

@ -32,12 +32,12 @@ class Message
* Contains the ITip method, which is something like REQUEST, REPLY or * Contains the ITip method, which is something like REQUEST, REPLY or
* CANCEL. * CANCEL.
*/ */
public ?string $method; public ?string $method = null;
/** /**
* The current sequence number for the event. * The current sequence number for the event.
*/ */
public ?int $sequence; public ?int $sequence = null;
/** /**
* The senders' email address. * The senders' email address.
@ -52,7 +52,7 @@ class Message
* The name of the sender. This is often populated from a CN parameter from * The name of the sender. This is often populated from a CN parameter from
* either the ORGANIZER or ATTENDEE, depending on the message. * either the ORGANIZER or ATTENDEE, depending on the message.
*/ */
public ?string $senderName; public ?string $senderName = null;
/** /**
* The recipient's email address. * The recipient's email address.
@ -63,7 +63,7 @@ class Message
* The name of the recipient. This is usually populated with the CN * The name of the recipient. This is usually populated with the CN
* parameter from the ATTENDEE or ORGANIZER property, if it's available. * parameter from the ATTENDEE or ORGANIZER property, if it's available.
*/ */
public ?string $recipientName; public ?string $recipientName = null;
/** /**
* After the message has been delivered, this should contain a string such * After the message has been delivered, this should contain a string such
@ -107,10 +107,9 @@ class Message
{ {
if (!$this->scheduleStatus) { if (!$this->scheduleStatus) {
return false; return false;
} else {
list($scheduleStatus) = explode(';', $this->scheduleStatus);
return $scheduleStatus;
} }
[$scheduleStatus] = explode(';', $this->scheduleStatus);
return $scheduleStatus;
} }
} }

View file

@ -42,7 +42,7 @@ abstract class Node implements \IteratorAggregate, \ArrayAccess, \Countable, \Js
/** /**
* Reference to the parent object, if this is not the top object. * Reference to the parent object, if this is not the top object.
*/ */
public ?Node $parent; public ?Node $parent = null;
/** /**
* Iterator override. * Iterator override.
@ -52,7 +52,7 @@ abstract class Node implements \IteratorAggregate, \ArrayAccess, \Countable, \Js
/** /**
* The root document. * The root document.
*/ */
protected ?Component $root; protected ?Component $root = null;
/** /**
* Serializes the node into a mimedir format. * Serializes the node into a mimedir format.

Some files were not shown because too many files have changed in this diff Show more