Compare commits

...

68 commits

Author SHA1 Message Date
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
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
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
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
Maarten
56409dcbb4
Merge branch 'master' into pgp-improvements 2025-06-16 17:28:39 +02:00
Philipp Mundhenk
a9b89fd7b3
Update instructions for Dovecot 2.4 2025-06-07 13:06:23 +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
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
dddf360db6
Add intermediate certificates for S/MIME signing 2025-01-07 21:32:26 +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
Sergey Mosin
c0348893af Delete OpenPGP keys on the server when they are deleted in the browser 2024-12-05 11:14:03 -05:00
48 changed files with 1581 additions and 644 deletions

View file

@ -122,6 +122,13 @@ RUN set -eux; \
apk del .build-dependencies; \
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
RUN set -eux; \
apk add --no-cache tidyhtml; \

View file

@ -9,6 +9,12 @@ UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
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
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

View file

@ -36,5 +36,5 @@ YMXQVq4SJt7UpWABAORudfJxsBqCRKtPlZMgGTJLjcOkyFJ9C2Fx7DeN0J4I
## Publishing and Credits
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.

View file

@ -260,7 +260,8 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
view => resolve({
password:view.passphrase(),
username:/*ask & 2 ? */view.username(),
remember:/*ask & 4 ? */view.remember()
remember:/*ask & 4 ? */view.remember(),
rememberPermanent: view.rememberPermanent(),
}),
() => resolve(null),
true,

4
dev/External/ko.js vendored
View file

@ -176,6 +176,6 @@ ko.extenders.falseTimeout = (target, option) => {
// functions
ko.observable.fn.askDeleteHelper = function() {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
};

View file

@ -24,6 +24,7 @@ export class IdentityModel extends EmailModel /*AbstractModel*/ {
smimeKey: '',
smimeCertificate: '',
smimeCertificateChain: '',
askDelete: false,
@ -33,7 +34,9 @@ export class IdentityModel extends EmailModel /*AbstractModel*/ {
addComputablesTo(this, {
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
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())
});
}

View file

@ -1,21 +1,155 @@
import { AskPopupView } from 'View/Popup/Ask';
import { SettingsUserStore } from 'Stores/User/Settings';
import { SettingsGet } from '../Common/Globals';
import { isArray } from '../Common/Utils';
export const Passphrases = new WeakMap();
Passphrases.ask = async (key, sAskDesc, btnText) =>
Passphrases.has(key)
? {password:Passphrases.handle(key)/*, remember:false*/}
: await AskPopupView.password(sAskDesc, btnText, 5);
Passphrases.ask = async (key, sAskDesc, btnText) => {
if (Passphrases.has(key)) {
return { password: Passphrases.handle(key)/*, remember:false*/ };
} 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 = {};
// get/set accessor to control deletion after N minutes of inactivity
Passphrases.handle = (key, pass) => {
const timeout = SettingsUserStore.keyPassForget();
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);
timeout && timeouts[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.askDelete = ko.observable(false);
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 = () => {
if (key.askDelete()) {
Remote.request('GnupgDeleteKey',
@ -69,6 +76,7 @@ export const GnuPGUserStore = new class {
isPrivate: isPrivate
}
);
isPrivate && key.forgetPass();
}
};
if (isPrivate) {

View file

@ -13,7 +13,7 @@ import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
import { Passphrases } from 'Storage/Passphrases';
import { baseCollator } from 'Common/Translator';
import { baseCollator, getNotification } from 'Common/Translator';
const
loaded = () => !!window.openpgp,
@ -90,6 +90,9 @@ class OpenPgpKeyModel {
this.armor = armor;
this.askDelete = ko.observable(false);
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.getPrimaryUser()
}
@ -115,12 +118,29 @@ class OpenPgpKeyModel {
if (this.key.isPrivate()) {
OpenPGPUserStore.privateKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
this.forgetPass()
} else {
OpenPGPUserStore.publicKeys.remove(this);
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() {
return this.armor;

View file

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

View file

@ -1413,7 +1413,8 @@ export class ComposePopupView extends AbstractViewPopup {
key && options.push(['OpenPGP', key]);
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
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']);
console.dir({signOptions: options});
this.signOptions(options);
@ -1593,6 +1594,7 @@ export class ComposePopupView extends AbstractViewPopup {
*/
break;
} catch (e) {
Passphrases.delete(signOptions[i][1])
console.error(e);
}
} else if ('GnuPG' == signOptions[i][0]) {
@ -1610,6 +1612,7 @@ export class ComposePopupView extends AbstractViewPopup {
// TODO: sign in PHP fails
params.sign = 'S/MIME';
// params.signCertificate = identity.smimeCertificate();
// params.signCertificateChain = identity.smimeCertificateChain();
// params.signPrivateKey = identity.smimeKey();
// params.attachCertificate = false;
if (identity.smimeKeyEncrypted()) {
@ -1634,7 +1637,9 @@ export class ComposePopupView extends AbstractViewPopup {
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
params.autocrypt.push({
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) {

View file

@ -303,14 +303,14 @@ export class MailMessageList extends AbstractViewRight {
item => sFolder === item?.folder && iUid == item?.uid
);
if ('INBOX' === sFolder) {
if ('INBOX' === sFolder && 'INBOX' !== FolderUserStore.currentFolderFullName()) {
hasher.setHash(mailBox(sFolder));
}
if (message) {
this.selector.selectMessageItem(message);
} else {
if ('INBOX' !== sFolder) {
if ('INBOX' !== sFolder && sFolder !== FolderUserStore.currentFolderFullName()) {
hasher.setHash(mailBox(sFolder));
}
if (sFolder && iUid) {

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:
- Type: `PostgresSQL`
- Data Source Name (DSN): `host=postgres;port=5432;dbname=snappymail`
- Data Source Name (DSN): `host=db;port=5432;dbname=snappymail`
- User `snappymail`
- Password `snappymail`

View file

@ -176,7 +176,8 @@ class SnappyMailHelper
// If OpenID Connect (OIDC) is enabled and used for login, use this.
if (static::isOIDCLogin()) {
$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

View file

@ -15,7 +15,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
REQUIRED = '2.33.0',
CATEGORY = 'Contacts',
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
{
@ -139,6 +139,10 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetAllowedInJs(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')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
@ -230,30 +234,23 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
\header('Cache-Control: private');
// \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);
if ($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) {
$sDomain = \explode('@', $sEmail);
@ -316,6 +313,77 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
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
{
if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) {

View file

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

View file

@ -86,10 +86,17 @@
});
}
});
// ICS attachment
// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType);
let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType);
// ICS attachment.
// attachments is a ko.observableArray, so it must be
// unwrapped before array methods are used on it.
// 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) {
// 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 .= ')';
$this->logWrite('ldap_search: start: '.$sBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP');
$oS = @\ldap_search($oCon, $sBaseDn, $sFilter, $aItems, 0, 30, 30);
if ($oS) {
$aEntries = @\ldap_get_entries($oCon, $oS);
if (is_array($aEntries)) {
if (isset($aEntries['count'])) {
unset($aEntries['count']);
}
// A directory rarely keeps everything worth suggesting in one branch:
// meeting rooms and other bookable resources commonly live outside
// the people branch. Searching a single subtree either misses them,
// or - if the base is widened to the domain root - drags every
// service account into the suggestion list. Base DNs are therefore
// separated by '|', which cannot appear unescaped in a DN, so an
// existing single-branch configuration keeps working unchanged.
$aBaseDns = \array_filter(\array_map('trim', \explode('|', $sBaseDn)), 'strlen');
foreach ($aEntries as $aItem) {
if ($aItem) {
$sName = $sEmail = '';
list ($sEmail, $sName) = $this->findNameAndEmail($aItem, $aEmails, $aNames, $aUIDs);
if (!empty($sEmail)) {
$aResult[] = array($sEmail, $sName);
foreach ($aBaseDns as $sOneBaseDn) {
$this->logWrite('ldap_search: start: '.$sOneBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP');
$oS = @\ldap_search($oCon, $sOneBaseDn, $sFilter, $aItems, 0, 30, 30);
if ($oS) {
$aEntries = @\ldap_get_entries($oCon, $oS);
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 {
$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
NAME = 'Contacts suggestions (LDAP)',
VERSION = '2.14',
RELEASE = '2024-03-12',
VERSION = '2.15',
RELEASE = '2026-08-19',
REQUIRED = '2.35.3',
CATEGORY = 'Contacts',
DESCRIPTION = 'Get contacts suggestions from LDAP.';
@ -79,7 +79,7 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetType(\RainLoop\Enumerations\PluginPropertyType::PASSWORD)
->SetDefaultValue(''),
\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'),
\RainLoop\Plugins\Property::NewInstance('object_classes')->SetLabel('objectClasses')
->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
NAME = 'LDAP login mapping',
VERSION = '2.3',
VERSION = '2.4',
AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>',
RELEASE = '2024-09-20',
RELEASE = '2025-01-09',
REQUIRED = '2.36.1',
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
*/
@ -104,6 +104,7 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
$sResult = $this->ldapSearch($sEmail);
if ( is_array($sResult) ) {
$sImapUser = $sResult['login'];
$sSmtpUser = $sResult['login'];
$sEmail = $sResult['email'];
}
syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginСredentials() 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 => {
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
query = rl.pluginSettingsGet('login-o365', 'personal') ? '' : '?',
tenant = rl.pluginSettingsGet('login-o365', 'tenant'),
login = () => {
document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({
response_type: 'code',
client_id: client_id,
redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365',
scope: [
// Associate personal info
'openid',
'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'
}));
};
((rl) => {
const client_id = rl.pluginSettingsGet("login-o365", "client_id"),
allowAnyDomain = !!rl.pluginSettingsGet("login-o365", "allow_any_domain"),
isSupportedEmail = (email) => {
email = (email || "").toLowerCase();
if (!email.includes("@")) return false;
if (allowAnyDomain) return true;
return /@(outlook\.com|hotmail\.com|live\.com)$/.test(email);
},
startOAuth = (op, opts = {}) => {
const email = (opts.email || "").toLowerCase();
const name = opts.name || "";
const returnHash = opts.return || "";
if (client_id) {
addEventListener('sm-user-login', e => {
if (event.detail.get('Email').includes('@hotmail.com')) {
e.preventDefault();
login();
}
});
// Server mints a signed state + correct redirect_uri and returns full authUrl.
rl.pluginRemoteRequest((iError, data) => {
const url = data?.Result?.authUrl;
if (!iError && url) {
document.location = url;
}
}, "LoginO365AuthUrl", {
op: op,
email: email,
name: name,
return: returnHash,
});
};
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 = login;
div.append(btn);
container && container.append(div);
}
});
}
if (client_id) {
addEventListener("sm-user-login", (e) => {
const email = (e.detail.get("Email") || "").toLowerCase();
if (isSupportedEmail(email)) {
e.preventDefault();
startOAuth("login", { email });
}
});
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);

View file

@ -1,15 +1,20 @@
<?php
/**
* Microsoft requires an Azure account that has an active subscription
* I'm not going to pay, so feel free to fix this code yourself.
* https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
* https://answers.microsoft.com/en-us/msoffice/forum/all/configuration-for-imap-pop-and-smtp-with-oauth-in/3db47d43-25ac-4e0b-b957-22585e6caf15
*
* https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps
*
* SnappyMail login-o365 plugin
* You need to register an app in Azure portal and add
* a secret, redirect URIs and the following API permissions:
* https://outlook.office.com/IMAP.AccessAsUser.All
* https://outlook.office.com/SMTP.Send
* openid offline_access email profile
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365
* Personal: redirect_uri=https://{DOMAIN}/LoginO365
* Query: 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;
@ -17,232 +22,674 @@ use RainLoop\Providers\Storage\Enumerations\StorageType;
class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Office365/Outlook OAuth2',
VERSION = '0.3',
RELEASE = '2024-09-29',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
const
NAME = 'Office365/Outlook OAuth2',
VERSION = '0.4',
RELEASE = '2025-12-22',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
// https://login.microsoftonline.com/{{tenant}}/v2.0/.well-known/openid-configuration
const
LOGIN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/auth',
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
// v2 endpoints
const
AUTH_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/authorize',
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
{
$this->UseLangs(true);
$this->addJs('LoginOAuth2.js');
$this->addHook('imap.before-login', 'clientLogin');
$this->addHook('smtp.before-login', 'clientLogin');
$this->addHook('sieve.before-login', 'clientLogin');
public function Init() : void
{
$this->UseLangs(true);
$this->addJs('LoginOAuth2.js');
$this->addHook('imap.before-login', 'clientLogin');
$this->addHook('smtp.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
$this->addHook('filter.http-paths', 'httpPaths');
}
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
$this->addHook('filter.http-paths', 'httpPaths');
public function httpPaths(array &$aPaths) : void
{
// Personal accounts workaround
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
$aPaths = ['LoginO365'];
}
// Cleanup: when an additional account is removed, also remove its encrypted refresh token bundle.
$this->addHook('json.after-AccountDelete', 'afterAccountDelete');
}
if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) {
$oConfig = \RainLoop\Api::Config();
$oConfig->Set('security', 'secfetch_allow',
\trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';')
);
}
}
public function httpPaths(array &$aPaths) : void
{
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
$aPaths = ['LoginO365'];
}
public function ServiceLoginO365() : string
{
$oActions = \RainLoop\Api::Actions();
$oHttp = $oActions->Http();
$oHttp->ServerNoCache();
if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) {
$oConfig = \RainLoop\Api::Config();
$oConfig->Set('security', 'secfetch_allow',
\trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';')
);
}
}
try
public function ServiceLoginO365() : string
{
$oActions = \RainLoop\Api::Actions();
$oHttp = $oActions->Http();
$oHttp->ServerNoCache();
try
{
if (isset($_GET['error'])) {
throw new \RuntimeException("{$_GET['error']}: {$_GET['error_description']}");
}
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;
}
if (isset($_GET['error'])) {
$desc = $_GET['error_description'] ?? '';
throw new \RuntimeException("{$_GET['error']}: {$desc}");
}
$iExpires = \time();
$aResponse = $oO365->getAccessToken(
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI),
'authorization_code',
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');
}
// Must have code + state
if (!isset($_GET['code']) || empty($_GET['state'])) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$sAccessToken = $aResponse['access_token'];
$iExpires += $aResponse['expires_in'];
$oO365 = $this->o365Connector();
if (!$oO365) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$oO365->setAccessToken($sAccessToken);
$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');
}
$iNow = \time();
static::$auth = [
'access_token' => $sAccessToken,
'refresh_token' => $aResponse['refresh_token'],
'expires_in' => $aResponse['expires_in'],
'expires' => $iExpires
];
$redirectUri = $this->redirectUri();
$oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']);
$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;
}
$tenant = $this->Config()->Get('plugin', 'tenant', 'common');
public function configMapping() : array
{
return [
\RainLoop\Plugins\Property::NewInstance('personal')
->SetLabel('Use with personal accounts')
->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()
];
}
$state = (string) $_GET['state'];
$statePayload = $this->verifyAndConsumeState($state);
if (!$statePayload) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void
{
if ($oAccount instanceof MainAccount && \str_ends_with($oAccount->Email(), '@hotmail.com')) {
$oActions = \RainLoop\Api::Actions();
try {
$aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON(
$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');
}
}
}
$aTokenWrap = $oO365->getAccessToken(
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
'authorization_code',
[
'code' => $_GET['code'],
'redirect_uri' => $redirectUri
]
);
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 (\Exception $oException)
{
$oActions->Logger()->WriteException($oException, \LOG_ERR);
}
}
return null;
}
}
if (!\is_array($aTokenWrap) || !isset($aTokenWrap['code'])) {
throw new \RuntimeException('Token request failed: ' . \json_encode($aTokenWrap));
}
if (200 !== (int)$aTokenWrap['code']) {
$err = $aTokenWrap['result']['error'] ?? '';
$desc = $aTokenWrap['result']['error_description'] ?? '';
throw new \RuntimeException("Token HTTP {$aTokenWrap['code']}: {$err} / {$desc}");
}
$aToken = $aTokenWrap['result'] ?? [];
$accessToken = $aToken['access_token'] ?? '';
$refreshToken = $aToken['refresh_token'] ?? '';
$expiresIn = (int)($aToken['expires_in'] ?? 0);
$idToken = $aToken['id_token'] ?? '';
if ($accessToken === '') {
throw new \RuntimeException('access_token missing');
}
if ($refreshToken === '') {
throw new \RuntimeException('refresh_token missing');
}
if ($idToken === '') {
// We rely on id_token to get email/sub without Graph.
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',
AUTHOR = 'SnappyMail',
URL = 'https://snappymail.eu/',
VERSION = '1.5',
RELEASE = '2024-09-20',
VERSION = '1.6',
RELEASE = '2026-10-22',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
LICENSE = 'MIT',
@ -43,6 +43,7 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
try
{
static::$login = true;
$sPassword = new \SnappyMail\SensitiveString($sPassword);
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
}
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 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".
- 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)
> **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.
### Dovecot
In Dovecot, you need to enable Master User.
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 {
driver = passwd-file
master = yes
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
}
```
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:
```
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.
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.
## 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',
AUTHOR = 'Philipp',
URL = 'https://www.mundhenk.org/',
VERSION = '0.5',
RELEASE = '2024-09-20',
VERSION = '0.7',
RELEASE = '2026-05-17',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
LICENSE = 'MIT',
@ -21,26 +21,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
$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)
{
$oActions = \RainLoop\Api::Actions();
@ -80,59 +60,19 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
$sMsg = "Remote User: " . $sRemoteUser;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
$sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', '');
$sMsg = "ProxyIP: " . $sProxyIP;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
/* create master user login from remote user header and settings */
$sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser;
$sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', '')));
$sProxyCheck = $this->Config()->getDecrypted('plugin', 'proxy_check', '');
$sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true);
/* make sure that remote user is only set by authorized proxy to avoid security risks */
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;
try
{
static::$login = true;
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
}
if ($sProxyRequest) {
/* create master user login from remote user header and settings */
$sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser;
$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;
catch (\Throwable $oException)
{
$oLogger = $oActions->Logger();
$oLogger && $oLogger->WriteException($oException);
}
\MailSo\Base\Http::Location('./');
@ -188,18 +128,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetDescription('Name of header containing username')
->SetDefaultValue('Remote-User')
->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')
->SetAllowedInJs(true)
->SetLabel('Activate automatic login')

View file

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

View file

@ -160,13 +160,20 @@ abstract class HtmlUtils
// Remove all remaining data-* attributes
foreach ($xpath->query('//*[@*[starts-with(name(), "data-")]]') as $oElement) {
$sTagNameLower = \strtolower($oElement->nodeName);
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) {
if ('data-' === \substr(\strtolower($oAttr->nodeName), 0, 5)) {
$oElement->removeAttribute($oAttr->nodeName);
$aNames[] = $oAttr->nodeName;
}
}
foreach ($aNames as $sName) {
$oElement->removeAttribute($sName);
}
}
}

View file

@ -39,7 +39,7 @@ class SSLContext implements \JsonSerializable
public function __construct()
{
$oConfig = \RainLoop\API::Config();
$oConfig = \RainLoop\Api::Config();
$this->verify_peer = !!$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);

View file

@ -14,6 +14,15 @@ trait Admin
public function IsAdminLoggined(bool $bThrowExceptionOnFalse = true) : bool
{
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();
if ($sAdminKey && $this->Cacher(null, true)->Get(KeyPathHelper::SessionAdminKey($sAdminKey))) {
return true;

View file

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

View file

@ -287,6 +287,14 @@ trait Pgp
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
*/

View file

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

View file

@ -160,6 +160,31 @@ class PdoAddressBook
$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
if ($bReadWrite) {
\SnappyMail\Log::info('PdoAddressBook', 'Sync() is import and export');
@ -168,7 +193,7 @@ class PdoAddressBook
if ($aData['deleted']) {
++$iCount;
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}"]);
$this->davClientRequest($oClient, 'DELETE', $sPath.$aRemoteSyncData[$sKey]['vcf']);
}
@ -183,9 +208,11 @@ class PdoAddressBook
// Delete local
$aIdsForDeletion = array();
foreach ($aLocalSyncData as $sKey => $aData) {
if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) {
$aIdsForDeletion[] = $aData['id_contact'];
if (!$bProtectLocal) {
foreach ($aLocalSyncData as $sKey => $aData) {
if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) {
$aIdsForDeletion[] = $aData['id_contact'];
}
}
}
if (\count($aIdsForDeletion)) {
@ -723,14 +750,20 @@ class PdoAddressBook
$aParams[':search_lower'] = array($sLowerSearch, \PDO::PARAM_STR);
}
$oContact = null;
$iIdContact = 0;
// This query selects id_contact only, so it must not be handed to
// 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(
$this->prepareAndExecute($sSql, $aParams)
);
return $aContacts ? $aContacts[0] : null;
return null;
}
/**
@ -997,7 +1030,7 @@ class PdoAddressBook
$sEmail = \trim($oItem->GetEmail(true));
if (\strlen($sEmail)) {
$aEmailsToUpdate[] = $sEmail;
return !\in_array($sEmail, $aExists);
return !\in_array(\mb_strtolower($sEmail), $aExists);
}
}
return false;

View file

@ -339,7 +339,7 @@ class ServiceActions
$sMethodName = 'Raw'.$sAction;
if (\method_exists($this->oActions, $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 = '';
$this->oActions->SetActionParams(array(

View file

@ -9,7 +9,7 @@ namespace SnappyMail\PGP;
class Backup
{
public static function PGPKey(string $key, string $keyId = '') : bool
public static function PGPKey(string $key, string $keyId = '', $doDelete = false) : bool
{
$oActions = \RainLoop\Api::Actions();
$oAccount = $oActions->getMainAccountFromToken();
@ -21,15 +21,23 @@ class Backup
true
);
if (\str_contains($key, 'PGP PRIVATE KEY')) {
$fileName = "{$dir}{$keyId}.key";
if ($doDelete) {
return !file_exists($fileName) || unlink($fileName);
}
$hash = $oAccount->CryptKey();
$key = \SnappyMail\Crypt::Encrypt($key, $hash);
$key[1] = \base64_encode($key[1]);
$key[2] = \base64_encode($key[2]);
$key[3] = \hash_hmac('sha1', $key[2], $hash);
return !!\file_put_contents("{$dir}{$keyId}.key", \json_encode($key));
return !!\file_put_contents($fileName, \json_encode($key));
}
if (\str_contains($key, 'PGP PUBLIC KEY')) {
return !!\file_put_contents("{$dir}{$keyId}_public.asc", $key);
$fileName = "{$dir}{$keyId}_public.asc";
if ($doDelete) {
return !file_exists($fileName) || unlink($fileName);
}
return !!\file_put_contents($fileName, $key);
}
}
return false;

View file

@ -19,6 +19,7 @@ class OpenSSL
// Used for sign and decrypt
private $certificate; // OpenSSLCertificate|array|string
private $privateKey; // OpenSSLAsymmetricKey|OpenSSLCertificate|array|string
private ?string $certificateChain = null;
function __construct(string $homedir)
{
@ -131,6 +132,15 @@ class OpenSSL
}
}
public function setCertificateChain(/*string*/$certificateChain)
{
if ($certificateChain === "") {
$this->certificateChain = null;
} else {
$this->certificateChain = $certificateChain;
}
}
public function setPrivateKey(/*OpenSSLAsymmetricKey|string*/$privateKey,
?\SnappyMail\SensitiveString $passphrase = null
) : void
@ -244,6 +254,13 @@ class OpenSSL
}
$input = $tmp;
}
if (\is_string($this->certificateChain)) {
$tmp = new Temporary('smimechain-');
if (!$tmp->putContents($this->certificateChain)) {
return null;
}
$certificateChain = $tmp;
}
$output = new Temporary('smimeout-');
if (!\openssl_pkcs7_sign(
$input->filename(),
@ -252,7 +269,7 @@ class OpenSSL
$this->privateKey,
$this->headers,
$detached ? \PKCS7_DETACHED | \PKCS7_BINARY : 0, // | PKCS7_NOCERTS | PKCS7_NOATTR
$this->untrusted_certificates_filename
$certificateChain ?? null
)) {
throw new \RuntimeException('OpenSSL sign: ' . \openssl_error_string());
}

View file

@ -27,6 +27,8 @@
"USERNAME": "Nutzername",
"PASSWORD": "Passwort",
"REMEMBER": "Zugangsdaten merken",
"REMEMBER_FOR_SESSION": "Aktuelle Sitzung merken",
"REMEMBER_PERMANENT": "Dauerhaft merken",
"REPLY_TO": "Antwort an",
"SAVE": "Speichern",
"SAVE_CHANGES": "Änderungen speichern?",
@ -331,6 +333,7 @@
"SMIME": {
"POPUP_IMPORT_TITLE": "S\/MIME-Zertifikat importieren",
"CERTIFICATE": "Zertifikat",
"CERTIFICATECHAIN": "Zwischenzertifikat(e)",
"CERTIFICATES": "S\/MIME-Zertifikate",
"SIGNED_MESSAGE": "S\/MIME-signierte Nachricht",
"ENCRYPTED_MESSAGE": "S\/MIME-verschlüsselte Nachricht",
@ -458,7 +461,8 @@
"LEGEND_SECURITY": "Sicherheit",
"LABEL_AUTOLOGOUT": "Automatische Abmeldung",
"FORGET_KEY_PASS": "Passphrase für privaten Schlüssel vergessen",
"NEVER": "Nie"
"NEVER": "Nie",
"LABEL_STORED_PASS": "Passphrase gespeichert"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Sprache",

View file

@ -27,6 +27,8 @@
"USERNAME": "Username",
"PASSWORD": "Passphrase",
"REMEMBER": "Remember",
"REMEMBER_FOR_SESSION": "Remember for this Session",
"REMEMBER_PERMANENT": "Remember Permanently",
"REPLY_TO": "Reply-To",
"SAVE": "Save",
"SAVE_CHANGES": "Save changes?",
@ -331,6 +333,7 @@
"SMIME": {
"POPUP_IMPORT_TITLE": "Import S\/MIME certificate",
"CERTIFICATE": "Certificate",
"CERTIFICATECHAIN": "Intermediate Certificate(s)",
"CERTIFICATES": "S\/MIME Certificates",
"SIGNED_MESSAGE": "S\/MIME signed message",
"ENCRYPTED_MESSAGE": "S\/MIME encrypted message",
@ -458,7 +461,8 @@
"LEGEND_SECURITY": "Security",
"LABEL_AUTOLOGOUT": "Auto Logout",
"FORGET_KEY_PASS": "Forget private key passphrase",
"NEVER": "Never"
"NEVER": "Never",
"LABEL_STORED_PASS": "Remembered Passphrase"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Language",

View file

@ -11,9 +11,9 @@
"CLOSE": "Itxi",
"CONTACTS": "Kontaktuak",
"DELETE": "Ezabatu",
"UNDELETE": "Undelete",
"UNDELETE": "Berreskuratu",
"DONE": "Eginda",
"EMAIL": "Email",
"EMAIL": "E-mail",
"FROM": "Nork",
"LOADING": "Kargatzen",
"LOGOUT": "Saioa itxi",
@ -22,11 +22,11 @@
"COPY_TO": "Kopiatu hona",
"NAME": "Izena",
"NONE": "Bat ere ez",
"TO_SPAM": "Move message(s) to Spam",
"TO_SPAM": "Eraman mezua(k) Spam-era",
"NOT_SPAM": "Ez da spama",
"USERNAME": "Erabiltzaile-izena",
"PASSWORD": "Pasahitza",
"REMEMBER": "Remember",
"REMEMBER": "Gogoratu",
"REPLY_TO": "Erantzun",
"SAVE": "Gorde",
"SAVE_CHANGES": "Aldaketak gorde?",
@ -45,7 +45,7 @@
"ATTACHMENTS": "Eranskinak"
},
"LOGIN": {
"LABEL_LOGIN": "Login",
"LABEL_LOGIN": "Hasi saioa",
"LABEL_SIGN_ME": "Gogora nazazu",
"BUTTON_SIGN_IN": "Sartu"
},
@ -60,12 +60,12 @@
"UNSEEN": "Irakurri gabe",
"DATE": "Data",
"DATE_ALL": "Denak",
"BEFORE_3_DAYS": "Older then 3 days",
"BEFORE_7_DAYS": "Older then 1 week",
"BEFORE_MONTH": "Older then 1 month",
"BEFORE_3_MONTHS": "Older then 3 months",
"BEFORE_6_MONTHS": "Older then 6 months",
"BEFORE_YEAR": "Older then 1 year",
"BEFORE_3_DAYS": "3 egun baino zaharragoa",
"BEFORE_7_DAYS": "Aste 1 baino zaharragoa",
"BEFORE_MONTH": "Hilabete 1 baino zaharragoa",
"BEFORE_3_MONTHS": "3 hilabete baino zaharragoa",
"BEFORE_6_MONTHS": "6 hilabete baino zaharragoa",
"BEFORE_YEAR": "Urte 1 baino zaharragoa",
"SINCE_3_DAYS": "3 egun arterakoak",
"SINCE_7_DAYS": "Astebetako arterakoak",
"SINCE_MONTH": "Hilabete arterakoak",
@ -89,26 +89,26 @@
"ARCHIVE_NAME": "Artxiboa"
},
"IMAP": {
"ACL_RIGHTS": "Rights",
"ACL_A": "Administer",
"ACL_E": "Expunge",
"ACL_I": "Insert messages",
"ACL_K": "Create sub-folders",
"ACL_L": "Show messages",
"ACL_P": "Send mail",
"ACL_R": "Select folder",
"ACL_S": "\\SEEN tag",
"ACL_T": "\\DELETED tag",
"ACL_W": "Other tags",
"ACL_X": "Delete\/Rename folder"
"ACL_RIGHTS": "Baimenak",
"ACL_A": "Administratu",
"ACL_E": "Ezabatu",
"ACL_I": "Txertatu mezuak",
"ACL_K": "Sortu azpi-karpetak",
"ACL_L": "Erakutsi mezuak",
"ACL_P": "Bidali posta",
"ACL_R": "Hautatu karpeta",
"ACL_S": "\\IKUSITAKO etiketa",
"ACL_T": "\\EZABATUTAKO etiketa",
"ACL_W": "Beste etiketak",
"ACL_X": "Ezabatu/Berrizendatu karpeta"
},
"MESSAGE_LIST": {
"BUTTON_RELOAD": "Eguneratu mezuen zerrenda",
"BUTTON_EMPTY_FOLDER": "Hustu karpeta",
"BUTTON_MULTY_FORWARD": "Erantsi bezala bidali",
"BUTTON_DELETE_WITHOUT_MOVE": "Beteriako ezabatu",
"MENU_SET_SEEN": "Irakurri bezala markatu",
"MENU_SET_ALL_SEEN": "Dena irakurri bezala markatu",
"BUTTON_MULTY_FORWARD": "Bidali eranskin gisa",
"BUTTON_DELETE_WITHOUT_MOVE": "Ezabatu betirako",
"MENU_SET_SEEN": "Irakurritako gisa markatu",
"MENU_SET_ALL_SEEN": "Dena irakurritako gisa markatu",
"MENU_UNSET_SEEN": "Irakurri gabe bezala markatu",
"MENU_SET_FLAG": "Markatu",
"MENU_UNSET_FLAG": "Desmarkatu",
@ -122,16 +122,16 @@
"EMPTY_LIST": "Hustu zerrenda.",
"EMPTY_SEARCH_LIST": "Zure bilaketarekin bat datorren mezurik ez.",
"SEARCH_RESULT_FOR": "\"%SEARCH%\"-erako bilaketa emaitzak",
"BACK_TO_MESSAGE_LIST": "itzuli mezuen zerrendara",
"BACK_TO_MESSAGE_LIST": "Itzuli mezuen zerrendara",
"PUT_MESSAGE_HERE": "Bota mezua hemen zerrendan ikusteko",
"TODAY_AT": "gaur %TIME%-etan",
"YESTERDAY_AT": "atzo %TIME%-etan",
"NEW_MESSAGE_NOTIFICATION": "%COUNT% mezu berri dituzu!",
"QUOTA_SIZE": "<strong>%SIZE% (%PROC%%)<\/strong> erabiliz zure <strong>%LIMIT%<\/strong>-tik",
"SORT": "Ordenatu",
"NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"NO_SORT": "Posta zerbitzariak ez du ordenatzen",
"SORT_ARRIVAL_ASC": "Zaharrenetik berrienera",
"SORT_ARRIVAL_DESC": "Berrienetik zaharrenera",
"SORT_DATE_ASC": "Data goraka",
"SORT_DATE_DESC": "Data beheraka",
"SORT_SIZE_ASC": "Tamaina goraka",
@ -160,7 +160,7 @@
"MENU_VIEW_ORIGINAL": "Erakutsi jatorrizkoa",
"HTML_VIEW": "Ikusi HTML mezua",
"PLAIN_VIEW": "Ikusi testu soil mezua",
"SWAP_COLORS": "Swap default (background)color",
"SWAP_COLORS": "Aldatu atzeko planoaren kolorea",
"MENU_DOWNLOAD_ORIGINAL": "Deskargatu .eml fitxategi bezala",
"MENU_FILTER_SIMILAR": "Iragazi hau bezalako mezuak",
"MENU_PRINT": "Inprimatu",
@ -172,12 +172,12 @@
"MESSAGE_VIEW_DESC": "Aukeratu zerrendako edozein mezua hemen ikusteko.",
"MESSAGE_VIEW_MOVE_DESC": "Klikatu karpetaren izena ezkerreko panelean helburua aukeratzeko.",
"LINK_DOWNLOAD_AS_ZIP": "Deskargatu zip gisa",
"SPAM_SCORE": "Spam Puntuzioa",
"SPAM_SCORE": "Spam puntuzioa",
"HAS_VIRUS_WARNING": "KONTUZ: virusa detektatu da",
"TAGS": "Etiketak",
"NEW_TAG": "Etiketa berria",
"ENABLE_TRACKING_LINKS": "Enable tracking links",
"TRACKING_ENABLED": "Links are now tracked when clicked!"
"ENABLE_TRACKING_LINKS": "Gaitu jarraipen-estekak",
"TRACKING_ENABLED": "Estekak orain jarraipena dute klikatzean!"
},
"MESSAGE_TAGS": {
"$important": "Garrantzitsua",
@ -221,14 +221,14 @@
"DISPLAY_NAME": "Erakusteko izena",
"LAST_NAME": "Abizenak",
"FIRST_NAME": "Izena",
"MIDDLE_NAME": "Middle name",
"NAME_PREFIX": "Name prefix",
"NAME_SUFFIX": "Name suffix",
"MIDDLE_NAME": "Bigarren izena",
"NAME_PREFIX": "Aurrizkia",
"NAME_SUFFIX": "Atzizkia",
"NICK_NAME": "Ezizena",
"LABEL_READ_ONLY": "Irakurtzeko bakarrik",
"ADD_MENU_LABEL": "Gehitu",
"ADD_MENU_NICKNAME": "Ezizena",
"ADD_MENU_NOTE": "Ezizena",
"ADD_MENU_NOTE": "Iruzkina",
"ADD_MENU_PHONE": "Telefonoa",
"ADD_MENU_URL": "URL",
"ADD_MENU_ADDRESS": "Helbidea",
@ -236,13 +236,13 @@
"ADD_MENU_TAGS": "Etiketak",
"BUTTON_SHARE_ALL": "Denak",
"BUTTON_SYNC": "Sinkronizazioa (CardDAV)",
"SEND_TO_ALL_CONTACT_EMAILS": "Send to all contact emails"
"SEND_TO_ALL_CONTACT_EMAILS": "Bidali kontaktu guztiei"
},
"COMPOSE": {
"LINK_SHOW_INPUTS": "erakutsi eremu guztiak",
"LINK_SHOW_INPUTS": "Erakutsi eremu guztiak",
"BUTTON_SEND": "Bidali",
"BUTTON_MINIMIZE": "Minimizatu",
"SAVED_TIME": "%TIME%-n gordea",
"SAVED_TIME": "%TIME%-etan gordea",
"SAVED_ERROR_ON_SEND": "Mezua bidali da baina ez da gorde bidalitako elementuen karpetan",
"DISCARD_UNSAVED_DATA": "Baztertu gorde gabeko datuak?",
"ATTACH_FILES": "Erantsi fitxategiak",
@ -250,20 +250,20 @@
"REPLY_MESSAGE_TITLE": "%DATETIME%, %EMAIL% idatzia",
"FORWARD_MESSAGE_TOP_TITLE": "-------- Birbidalitako mezua -------",
"FORWARD_MESSAGE_TOP_SENT": "Bidalita",
"EMPTY_TO_ERROR_DESC": "Mesedez, gehitu gutxienez jasotzaile bat",
"EMPTY_TO_ERROR_DESC": "Gehitu jasotzaile bat gutxienez",
"NO_ATTACHMENTS_HERE_DESC": "Ez dago eranskinik hemen.",
"ATTACHMENTS_ERROR_DESC": "Kontuz! Eranskin guztiak ez dira kargatu.",
"ATTACHMENTS_UPLOAD_ERROR_DESC": "Eranskin guztiak ez dira kargatu oraindik.",
"ALLOW_SPELLCHECK": "Gaitu nabigatzaileko hizkuntza-zuzentzailea",
"ALLOW_DRAFT_AUTOSAVE": "Automatikoi gorde zirriborroa",
"ALLOW_DRAFT_AUTOSAVE": "Automatikoki gorde zirriborroa",
"BUTTON_REQUEST_READ_RECEIPT": "Eskatu irakurketa-agiria",
"BUTTON_MARK_AS_IMPORTANT": "MArkatu garrantzitsu bezala",
"BUTTON_MARK_AS_IMPORTANT": "Markatu garrantzitsu gisa",
"BUTTON_REQUEST_DSN": "Eskatu jasotze-agiria",
"BUTTON_REQUIRE_TLS": "Require secure delivery",
"ERROR_EMPTY_BODY": "Message body is empty"
"BUTTON_REQUIRE_TLS": "Eskatu entrega segurua",
"ERROR_EMPTY_BODY": "Mezua hutsik dago"
},
"POPUPS_ASK": {
"EXIT_ARE_YOU_SURE": "Are you sure you want to exit?",
"EXIT_ARE_YOU_SURE": "Ziur zaude irten nahi duzula?",
"DESC_WANT_CLOSE_THIS_WINDOW": "Ziur zaude leiho hau itxi nahi duzula?",
"DESC_WANT_DELETE_MESSAGES": "Ziur zaude mezua(k) ezabatu nahi d(it)uzula?"
},
@ -271,20 +271,20 @@
"TITLE_LANGUAGES": "Aukeratu zure hizkuntza"
},
"POPUPS_ADD_ACCOUNT": {
"TITLE_ADD_ACCOUNT": "Kontua gehitu",
"TITLE_ADD_ACCOUNT": "Gehitu kontua",
"BUTTON_ADD_ACCOUNT": "Gehitu",
"TITLE_UPDATE_ACCOUNT": "Kontua eguneratu"
"TITLE_UPDATE_ACCOUNT": "Eguneratu kontua"
},
"POPUPS_IDENTITY": {
"TITLE_ADD_IDENTITY": "Nortasuna gehitu",
"TITLE_UPDATE_IDENTITY": "Nortasuna eguneratu",
"LABEL": "Label",
"TITLE_ADD_IDENTITY": "Gehitu nortasuna",
"TITLE_UPDATE_IDENTITY": "Eguneratu nortasuna",
"LABEL": "Etiketa",
"BUTTON_ADD_IDENTITY": "Gehitu",
"SIGNATURE": "Signature",
"LABEL_SIGNATURE_INSERT_BEFORE": "Txertatu sinadura hau erantzuteko testuaren aurretik"
"SIGNATURE": "Sinadura",
"LABEL_SIGNATURE_INSERT_BEFORE": "Txertatu sinadura hau erantzunaren aurretik"
},
"POPUPS_CREATE_FOLDER": {
"TITLE_CREATE_FOLDER": "Karpeta sortu",
"TITLE_CREATE_FOLDER": "Sortu karpeta",
"LABEL_PARENT": "Karpeta gurasoa",
"BUTTON_CREATE": "Sortu",
"TITLE_CREATING_PROCESS": "Karpeta sortzen"
@ -303,13 +303,13 @@
"VERIFY": "Egiaztatu",
"GOOD_SIGNATURE": "%USER%-en sinadura egokia",
"ERROR": "%TYPE% errorea: %ERROR%",
"CREATE_SELF_SIGNED": "Create self-signed",
"VALID_UNTIL": "Valid until",
"PRIVATE_KEY": "Private key",
"CHANGE_PASS": "Change passphrase",
"CURRENT_PASS": "Current passphrase",
"NEW_PASS": "New passphrase",
"ASK_CRYPTKEY_PASS": "Your login passphrase has changed. Please fill in your old login password to decrypt data"
"CREATE_SELF_SIGNED": "Sortu sinadura automatikoa",
"VALID_UNTIL": "Noiz arte balio du",
"PRIVATE_KEY": "Gako pribatua",
"CHANGE_PASS": "Aldatu pasaesaldia",
"CURRENT_PASS": "Uneko pasaesaldia",
"NEW_PASS": "Pasaesaldi berria",
"ASK_CRYPTKEY_PASS": "Saioaren pasahitza aldatu egin da. Bete zure saio-hasierako pasahitz zaharrean deszifratzeko"
},
"OPENPGP": {
"POPUP_IMPORT_TITLE": "Inportatu OpenPGP gakoa",
@ -322,19 +322,19 @@
"SIGNED_MESSAGE": "OpenPGP sinatutako mezua",
"ENCRYPTED_MESSAGE": "OpenPGP zifratutako mezua",
"STORE_IN_GNUPG": "Zerbitzarian gorden GnuPG-n",
"STORE_PUBLIC_KEY_IN_GNUPG": "Store public key on server in GnuPG",
"STORE_PRIVATE_KEY_IN_GNUPG": "Store private key on server in GnuPG",
"BACKUP_ON_SERVER": "Zerbitzarian gorde (zifratuta)",
"BACKUP_PUBLIC_KEY_ON_SERVER": "Backup public key on server",
"BACKUP_PRIVATE_KEY_ON_SERVER": "Backup private key on server"
"STORE_PUBLIC_KEY_IN_GNUPG": "Gorde gako publikoa GnuPGko zerbitzarian",
"STORE_PRIVATE_KEY_IN_GNUPG": "Gorde gako pribatua GnuPGko zerbitzarian",
"BACKUP_ON_SERVER": "Gorde zerbitzarian (zifratuta)",
"BACKUP_PUBLIC_KEY_ON_SERVER": "Gorde gako publikoa zerbitzarian",
"BACKUP_PRIVATE_KEY_ON_SERVER": "Gorde gako pribatua zerbitzarian"
},
"SMIME": {
"POPUP_IMPORT_TITLE": "Import S\/MIME certificate",
"CERTIFICATE": "Certificate",
"CERTIFICATES": "S\/MIME Certificates",
"POPUP_IMPORT_TITLE": "Inportatu S\/MIME ziurtagiria",
"CERTIFICATE": "Ziurtagiria",
"CERTIFICATES": "S\/MIME ziurtagiria",
"SIGNED_MESSAGE": "S\/MIME sinatutako mezua",
"ENCRYPTED_MESSAGE": "S\/MIME zifratutako mezua",
"PRIVATE_KEY_OF": "S\/MIME private key of %EMAIL%"
"PRIVATE_KEY_OF": "S\/MIME %EMAIL%-ren gako pribatua"
},
"POPUPS_FILTER": {
"TITLE_CREATE_FILTER": "Sortu iragazkia",
@ -362,13 +362,13 @@
"SELECT_TYPE_OVER": "Gainean",
"SELECT_TYPE_UNDER": "Azpian",
"SELECT_TYPE_TEXT": "Testua",
"SELECT_TYPE_RAW": "Raw",
"SELECT_TYPE_RAW": "Gordina",
"SELECT_MATCH_ANY": "Bat etortzeak hurrengo EDOZEIN araurekin",
"SELECT_MATCH_ALL": "Bat etortzeak hurrengo arau GUZTIEKIN",
"MARK_AS_READ_LABEL": "Markatu irakurri bezala",
"REPLY_INTERVAL_LABEL": "Erantzuteko tartea (egunak)",
"KEEP_LABEL": "Mantenu",
"STOP_LABEL": "Stop further processing of rules",
"KEEP_LABEL": "Mantenua",
"STOP_LABEL": "Utzi arauak gehiago izapidetzeari",
"VACATION_SUBJECT_LABEL": "Gaia (hautazkoa)",
"VACATION_MESSAGE_LABEL": "Mezua",
"VACATION_RECIPIENTS_LABEL": "Hartzaileak (komaz bananduta)",
@ -376,8 +376,8 @@
"ALL_INCOMING_MESSAGES_DESC": "Iristen diren mezu guztiak"
},
"POPUPS_SIEVE_SCRIPT": {
"TITLE_CREATE": "Sortu Scripta",
"TITLE_EDIT": "Editatu Scripta",
"TITLE_CREATE": "Sortu scripta",
"TITLE_EDIT": "Editatu scripta",
"BUTTON_ADD_FILTER": "Gehitu iragazkia",
"BUTTON_RAW_SCRIPT": "Erabili erabiltzailearen script pertsonal bat",
"CAPABILITY_LABEL": "Gaitasunak",
@ -403,37 +403,37 @@
"ERROR_NO_FILE_UPLOADED": "Ez da fitxategirik kargatu",
"ERROR_MISSING_TEMP_FOLDER": "Fitxategi tenporala falta da",
"ERROR_ON_SAVING_FILE": "Fitxategi-kargako errore ezezagun bat gertatu da",
"ERROR_FILE_TYPE": "Ez baliozko fitxategi-mota",
"ERROR_FILE_TYPE": "Fitxategi-mota ez baliozkoa",
"ERROR_UNKNOWN": "Fitxategi-kargako errore ezezagun bat gertatu da"
},
"EDITOR": {
"PLAIN": "HTML <-> TESTUA",
"SOURCE": "HTML <-> JATORRIA",
"FONTFAMILY": "Font family",
"FONTSIZE": "Font size",
"DIR": "Text direction",
"DIR_LTR": "Left to right",
"DIR_RTL": "Right to left",
"DIR_AUTO": "Automatic",
"TEXTCOLOR": "Text color",
"BACKGROUNDCOLOR": "Background color",
"BOLD": "Bold",
"ITALIC": "Italic",
"UNDERLINE": "Underline",
"STRIKE": "Strikethrough",
"SUB": "Subscript",
"SUP": "Superscript",
"OL": "Ordered list",
"UL": "Unordered list",
"QUOTE": "Blockquote",
"INDENTDECREASE": "Decrease indent",
"INDENTINCREASE": "Increase indent",
"LINK": "Link",
"IMAGEURL": "Image URL",
"IMAGEUPLOAD": "Image select",
"UNDO": "Undo",
"REDO": "Redo",
"REMOVESTYLE": "Remove style"
"FONTFAMILY": "Letra mota",
"FONTSIZE": "Letra tamaina",
"DIR": "Testuaren noranzkoa",
"DIR_LTR": "Eskerretik eskuinera",
"DIR_RTL": "Eskuinetik eskerrera",
"DIR_AUTO": "Automatikoa",
"TEXTCOLOR": "Testuaren kolorea",
"BACKGROUNDCOLOR": "Atzeko planoaren kolorea",
"BOLD": "Lodia",
"ITALIC": "Etzana",
"UNDERLINE": "Azpimarratua",
"STRIKE": "Marratua",
"SUB": "Azpi-indizea",
"SUP": "Goi-indizea",
"OL": "Zerrenda ordenatua",
"UL": "Ordenik gabeko zerrenda",
"QUOTE": "Aipua",
"INDENTDECREASE": "Txikitu koska",
"INDENTINCREASE": "Handitu koska",
"LINK": "Esteka",
"IMAGEURL": "Irudiaren URLa",
"IMAGEUPLOAD": "Aukeratu irudia",
"UNDO": "Desegin",
"REDO": "Berregin",
"REMOVESTYLE": "Kendu estiloa"
},
"SETTINGS_LABELS": {
"GENERAL": "Orokorra",
@ -447,7 +447,7 @@
"SETTINGS_FILTERS": {
"SIMPLE": "Arrunta",
"ADVANCED": "Aurretatua",
"BUTTON_ADD_SCRIPT": "Gehitu Scripta",
"BUTTON_ADD_SCRIPT": "Gehitu scripta",
"SUBNAME_MOVE_TO": "Mugitu \"%FOLDER%\"-era",
"SUBNAME_FORWARD_TO": "Birbidali \"%EMAIL%\"-era",
"SUBNAME_REJECT": "Errefusatu",
@ -457,18 +457,18 @@
"SETTINGS_SECURITY": {
"LEGEND_SECURITY": "Segurtasuna",
"LABEL_AUTOLOGOUT": "Saioa automatikoki itxi",
"FORGET_KEY_PASS": "Forget private key passphrase",
"NEVER": "Inoiz"
"FORGET_KEY_PASS": "Ahaztu gako pribatuaren pasaesaldia",
"NEVER": "Inoiz ez"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Hizkuntza",
"HOUR_FORMAT": "Hour format",
"HOUR_FORMAT": "Ordu-formatua",
"IDENTITY": "Nortasuna",
"LAYOUT": "Diseinua",
"LAYOUT_NO_SPLIT": "Zatitu gabe",
"LAYOUT_VERTICAL_SPLIT": "Zatiketa bertikala",
"LAYOUT_HORIZONTAL_SPLIT": "Zatiketa horizontala",
"SHOW_NEXT_MESSAGE": "Show next message when (re)move current message",
"SHOW_NEXT_MESSAGE": "Erakutsi hurrengo mezua unekoa mugitu edo ezabatzean",
"EDITOR": "Berezko testu editorea",
"EDITOR_HTML": "Html",
"EDITOR_PLAIN": "Formaturik gabea",
@ -477,36 +477,36 @@
"HIDE_DELETED_MESSAGES": "Ezkutatu ezabatzeko markatutako mezuak",
"USE_CHECKBOXES_IN_LIST": "Bistaratu kontrol laukiak zerrendan",
"USE_THREADS": "Erabili hariak",
"THREAD_ALGORITHM": "Threads algorithm",
"THREAD_ALGORITHM": "Harien algoritmoa",
"REPLY_SAME_FOLDER": "Kokatu erantzunak erantzundako mezuaren karpeta berean",
"PREFER_HTML": "Lehenetsi HTMLa testu arruntari",
"PREFER_HTML_INFO": "Mezuak batzuetan bi formatuetan datoz. Aukera honekin HTML edo testu arrunt partea erakustea aukera dezakezu.",
"REMOVE_COLORS": "Kendu atzealdeko irudia eta testuaren kolorean mezuaren gorputzetik",
"ALLOW_STYLES": "Allow <style> CSS",
"ALLOW_STYLES": "Gaitu <style> CSS",
"MESSAGE_PER_PAGE": "Orrialdeko mezuak",
"CHECK_MAIL_INTERVAL": "Check mail interval",
"CHECK_MAIL_INTERVAL": "Posta begiratzeko tartea",
"MARK_MESSAGE_READ_AFTER": "Markatu mezua irakurrita ondoren",
"MSG_DEFAULT_ACTION": "Lehenetsitako ekintza",
"SECONDS": "segundu",
"MINUTES": "minutes",
"SECONDS": "segundo",
"MINUTES": "minutu",
"NOTIFICATIONS": "Jakinarazpenak",
"SOUND_NOTIFICATION": "Soinu-jakinarazpena",
"CHROME_NOTIFICATION_DESC": "Erakutsi mezu berrien jakinarazpen popup-ak",
"CHROME_NOTIFICATION_DESC_DENIED": "(Nabigatzaileak blokeatuta)",
"COLLAPSE_BLOCKQUOTES": "Collapse quoted text",
"MAX_QUOTE_LEVEL": "Maximum visible quoted text levels",
"COLLAPSE_BLOCKQUOTES": "Bildu aipuko testua",
"MAX_QUOTE_LEVEL": "Ageriko aipamen-maila maximoak",
"MIN_FONT_SIZE": "Minimum Font Size",
"LIST_INLINE_ATTACHMENTS": "Erakutsi sareko eranskinak zerrendan",
"LIST_SIMPLE_ATTACHMENTS": "Eranskinen zerrenda arrunta",
"LIST_MESSAGES_GROUPED": "List messages grouped (when sorting by date or from)",
"EXTERNAL_IMAGES": "External images",
"IMAGES_OPTION_ASK": "Always ask",
"IMAGES_OPTION_WHITELIST": "Whitelist or ask",
"IMAGES_OPTION_ALWAYS": "Show always",
"IMAGES_WHITELIST": "Images whitelist",
"MESSAGE_POPUP_WINDOW": "Popup in new window instead of tab",
"MAILTO": "Register as 'mailto:' links handler",
"HTML_TO_MD": "Convert HTML to Markdown instead of plain text"
"LIST_MESSAGES_GROUPED": "Zerrendatu mezuak taldekatuak (ordena jatorria edo data denean)",
"EXTERNAL_IMAGES": "Kanpoko irudiak",
"IMAGES_OPTION_ASK": "Galdetu beti",
"IMAGES_OPTION_WHITELIST": "Zerrenda zuria edo galdetu",
"IMAGES_OPTION_ALWAYS": "Erakutsi bete",
"IMAGES_WHITELIST": "Irudien zerrenda zuria",
"MESSAGE_POPUP_WINDOW": "Agertu popup-ean fitxa berrian ordez",
"MAILTO": "Erregistratu 'mailto:' esteken kudeatzaile gisa",
"HTML_TO_MD": "Bihurtu HTML Markdown-era formaturik gabeko testuraren ordez"
},
"SETTINGS_CONTACTS": {
"LABEL_CONTACTS_AUTOSAVE": "Automatikoki gehitu hartzaileak helbide-liburura",
@ -558,12 +558,12 @@
"BUTTON_ADD_ACCOUNT": "Gehitu kontu bat",
"BUTTON_ADD_IDENTITY": "Gehitu identitate bat",
"LOADING_PROCESS": "Eguneratzen...",
"SHOW_UNREAD": "Show unread messages count"
"SHOW_UNREAD": "Erakutsi irakurri gabeko mezuen kopurua"
},
"SETTINGS_OPENPGP": {
"BUTTON_IMPORT_KEY": "Inportatu Gakoa",
"BUTTON_GENERATE_KEY_PAIR": "Sortu Gako Parea",
"IMPORT_FROM_SERVER": "Import from server",
"IMPORT_FROM_SERVER": "Inportatu zerbitzaritik",
"TITLE_PRIVATE": "Pribatua",
"TITLE_PUBLIC": "Publikoa",
"GET_MAILVELOPE": "Lortu Mailvelope nabigatzaileko gehigarria."

View file

@ -27,6 +27,8 @@
"USERNAME": "Gebruikersnaam",
"PASSWORD": "Wachtwoord",
"REMEMBER": "Onthouden",
"REMEMBER_FOR_SESSION": "Onthoud voor deze sessie",
"REMEMBER_PERMANENT": "Onthoud permanent",
"REPLY_TO": "Antwoordadres",
"SAVE": "Opslaan",
"SAVE_CHANGES": "Wijzigingen opslaan?",
@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Beveiliging",
"LABEL_AUTOLOGOUT": "Automatisch uitloggen",
"FORGET_KEY_PASS": "Vergeet privésleutel wachtwoord",
"NEVER": "Nooit"
"NEVER": "Nooit",
"LABEL_STORED_PASS": "Wachtwoordzin opgeslagen"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Taal",

View file

@ -27,6 +27,8 @@
"USERNAME": "Имя пользователя",
"PASSWORD": "Пароль",
"REMEMBER": "Запомнить",
"REMEMBER_FOR_SESSION": "Запомнить Временное",
"REMEMBER_PERMANENT": "Запомнить Навсегда",
"REPLY_TO": "Ответить-на",
"SAVE": "Сохранить",
"SAVE_CHANGES": "Сохранить изменения?",
@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Безопасность",
"LABEL_AUTOLOGOUT": "Автоматический выход",
"FORGET_KEY_PASS": "Забыть парольную фразу закрытого ключа",
"NEVER": "Никогда"
"NEVER": "Никогда",
"LABEL_STORED_PASS": "Remembered Passphrase"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Язык",

View file

@ -9,13 +9,23 @@
<label data-i18n="GLOBAL/PASSWORD"></label>
<input type="password" data-bind="value: passphrase">
</div>
<div data-bind="visible: askRemeber, component: {
<div class="control-group" style="display: inline-flex; align-items: start;flex-direction: column;">
<div data-bind="visible: askRemeber, component: {
name: 'Checkbox',
params: {
label: 'GLOBAL/REMEMBER',
value: remember
label: 'GLOBAL/REMEMBER_FOR_SESSION',
value: remember,
enable: !rememberPermanent()
}
}"></div>
<div data-bind="visible: askRememberPermanent, component: {
name: 'Checkbox',
params: {
label: 'GLOBAL/REMEMBER_PERMANENT',
value: rememberPermanent
}
}"></div>
</div>
</form>
</div>
<footer>

View file

@ -101,6 +101,10 @@
<label data-i18n="SMIME/CERTIFICATE"></label>
<textarea name="smimeCertificate" class="input-xxlarge" rows="14" autofocus="" autocomplete="off" data-bind="value: smimeCertificate"></textarea>
</div>
<div class="control-group" data-bind="css: {'error': smimeCertificateChain() && !smimeCertificateChainValid()}">
<label data-i18n="SMIME/CERTIFICATECHAIN"></label>
<textarea name="smimeCertificateChain" class="input-xxlarge" rows="14" autofocus="" autocomplete="off" data-bind="value: smimeCertificateChain"></textarea>
</div>
<div class="control-group" data-bind="hidden:smimeCertificate">
<label></label>
<button type="button" data-bind="click: $root.createSelfSigned" data-i18n="CRYPTO/CREATE_SELF_SIGNED"></button>

View file

@ -58,6 +58,20 @@
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
</td>
</tr>
<tr data-bind="visible: hasStoredPass">
<td>
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
<span class="key-id" data-bind="text: id"></span>
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
</td>
<td>
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
</td>
<td>
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
</td>
</tr>
</tbody>
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
<tbody data-bind="foreach: gnupgPublicKeys, i18nUpdate: gnupgPublicKeys">
@ -105,6 +119,20 @@
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
</td>
</tr>
<tr data-bind="visible: hasStoredPass">
<td>
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
<span class="key-id" data-bind="text: id"></span>
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
</td>
<td>
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
</td>
<td>
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
</td>
</tr>
</tbody>
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
<tbody data-bind="foreach: openpgpkeysPublic, i18nUpdate: openpgpkeysPublic">

View file

@ -183,7 +183,7 @@ function getImapClient(int $host)
$oImapClient = new \MailSo\Imap\ImapClient;
// $oAccount = new \RainLoop\Model\Account;
$oImapClient->SetLogger(\RainLoop\API::Logger());
$oImapClient->SetLogger(\RainLoop\Api::Logger());
// $oPlugins->RunHook('imap.before-connect', array($oAccount, $oImapClient, $ImapSettings));
$oImapClient->Connect($ImapSettings);
// $oPlugins->RunHook('imap.after-connect', array($oAccount, $oImapClient, $ImapSettings));